Skip to content

Cloud documents, sharing and shared editing, through one service - #1087

Open
wdunn001 wants to merge 13 commits into
storytold:mainfrom
wdunn001:collab
Open

wdunn001 wants to merge 13 commits into
storytold:mainfrom
wdunn001:collab

Conversation

@wdunn001

@wdunn001 wdunn001 commented Oct 13, 2026 •

Copy link
Copy Markdown

Summary

Open, Save, Share, and shared editing against CollabCraft in GridCraft.

Depends on storytold/craft-libs#48 and #52. Pins currently on the fork until those land.


Related to storytold/craft-libs#123 (Add collab-craft repo and accept transfer).

@wdunn001

Copy link
Copy Markdown
Author

The controls, rendered

Written by renders_for_review in crates/ui-egui/tests/cloud_files.rs. It
drives the same fake provider the behaviour tests use, through an offscreen
renderer, at two device pixels per point. The test is skipped unless
GRIDCRAFT_CLOUD_RENDERS names a directory.

Who else is in the workbook. Four badges and a count, beside the account.
Nothing is drawn while a person works alone.

presence

Share as a split button. The label opens the dialog. The chevron holds
Work together and the Save a Copy formats.

split button

The share dialog, with access the provider reported. The link row carries
Copy link. The user row carries the name the provider gave, in place of the
account id it used to print.

share dialog

The Open page. Two columns, the user's files and what other people shared.

open page

The overflow on a document. Open, Share and Work together. A folder has no menu. Only the
documents carry the ellipsis.

overflow

@wdunn001

Copy link
Copy Markdown
Author

The pages moved to craft-libs and the pixels did not change

84fa925 moves the cloud Open and Save pages into craft_ui::cloud, where
eleven other applications can draw them. This module goes from 1328 lines to
243. The wiring that remains is an impl CloudApp for SheetApp, a backend
handle and a DocumentKind of four fields.

Two pieces of evidence that nothing a user sees moved with it.

The fifteen behaviour tests in crates/ui-egui/tests/cloud_files.rs are the
contract. Not one assertion in them changed. The harness changed in two places, both of
them the seam. The fake service implements CloudBackend now. It arrives
through app.cloud.connect where it used to arrive through Services.

The five renders are byte identical. I rendered them from the build before the
move and from the build after it, then compared the files.

gridcraft-open-page                  identical
gridcraft-open-page-overflow         identical
gridcraft-title-bar-presence         identical
gridcraft-share-split-menu           identical
gridcraft-share-dialog               identical

Gates on the merged revisions: fmt, clippy under -D warnings across the
workspace and all targets, cargo xtask layers, cargo xtask wasm, the whole
test suite at 92 groups.

@wdunn001

Copy link
Copy Markdown
Author

A cloud workbook now saves in the format its name asks for

785916f fixes a defect this branch introduced and nothing caught.

encode hardcoded workbook.xlsx. The name of the destination was the one
thing the pages never told it. Open figures.csv from the cloud, edit a cell,
save. The service then holds XLSX bytes under the name figures.csv. Download
it later and nothing opens it. Nothing ever said so.

The pages hand over the destination now, as a name and the content type the
provider gave. The name decides, through the same save_bytes the desktop Save
As uses. That refuses an extension this application cannot write and names the
ones it can. A .xls from the cloud now says to Save As .xlsx. It used to
become one quietly.

A provider that issued no extension still says what the thing is. The fixture
in these tests is exactly that case, a CSV named exported with a correct
content type and no suffix. The content type decides for the four formats a
bare name can mean here. Everything else this application opens is a package
format whose writer wants a real name.

DeckCraft found it. That application hit the same defect against .pptx while
wiring the shared pages. The contract change is upstream in craft-libs.

One new test opens the extensionless CSV, edits it, saves, and asserts the
stored bytes are not a zip header.

@wdunn001

Copy link
Copy Markdown
Author

Responsive title-bar fix pushed and deployed.

  • Below 560 CSS px, AutoSave moves into the existing More menu so the cloud account, presence and Share controls no longer overlap the QAT.
  • cargo check and clippy passed.
  • Live wasm: 553fd47b7e5c4084eb45c134ebab7ba59b286dce (gridcraft-web-7f7b5ee675e2886d_bg.wasm).

@wdunn001
wdunn001 marked this pull request as ready for review October 15, 2026 02:41
wdunn001 and others added 13 commits October 15, 2026 16:54
GridCraft now joins a CraftCollab room and both directions work. Verified from
a cross-compiled Windows binary installed on a workstation, against the live
relay on .88 with tokens minted by the Nextcloud app:

* a peer elsewhere set B2 to 7 and the installed app's own workbook holds 7.0
* the installed app set D4 to 42 and a listening peer logged
  `APPLY cell.set {"cell":"D4","input":"42"}`, ordered at seq 25

Three contracts carry this. Nothing here is tied to one vendor.

`gridcraft_engine::CollabSink` is the only thing the engine gains. It hears
about a local command and knows nothing about what is listening. A relay, a log
and a test double are the same shape from there. Ordering, locking, transport
and credentials all stay outside the engine.

`craft_collab::Hub` is the transport, already defined in craft-libs.

`TokenSource` is how a credential arrives. `EnvToken` reads the environment.
That is the path `craft-collab`'s own `join` example already uses. A token
therefore works in both without translation.

`Session::execute` is the single chokepoint every command passes through. That
is where a local edit is forwarded. The predicate is `spec.journal`. It already
means "this changed the workbook". Commands that only move the view or
open a dialog are not journaled and are therefore not shared. That is correct
without a second list to maintain.

Inbound edits run through `Session::apply_remote`, identical to `execute`
except that it does not report to the sink. Without that an edit echoes between
two peers forever. It still journals and still records undo history. A
remote edit can be undone locally like any other.

The room is keyed on the Nextcloud file id and never on a path. A rename
mid-session therefore does not split the peers. `craft_collab::Session` discards
any message whose doc does not match its own. The token carries the file id.
The client derives a starting room from that, then lets the relay's own
`joined` replace it. Guessing it wrong looks exactly like a relay that answers
nothing.

Nothing is sent on connect. The relay admits a peer from the token in the
handshake and has no handler for a client-sent join. Sending one raced
socketioxide's namespace registration and cost a duplicate session every time.

`App` became a named-field struct. The tuple could not take another field
without the index shifting on macOS only, where two fields sit behind a cfg.

A frame asks for a repaint while a link is live. egui draws when something
happens locally. Another person typing is not a local event. Without that
repaint a remote edit sits in the socket until the mouse moves.

`craft-collab` is a path dependency on craft-libs, checked out beside this
repository. The wire protocol lives there because every Craft app has to speak
the same one.

Windows binaries cross-compile with cargo-xwin in a `rust:1-slim` container,
needing `clang lld llvm` and no MSVC toolchain on the target machine. Install
`llvm` as well as clang and lld: without it `build.rs` cannot find `llvm-rc`
and the binary ships with no icon and no VERSIONINFO, logging a warning and
carrying on.
GridCraft could co-edit a blank workbook and nothing else. It opened no real
document and saved nowhere. Its Share button was an export affordance with no
provider behind it. All three work now. No vendor is named anywhere.

Every call goes through `craft-cloud-client` to CloudCraft. The app holds no
provider credential, knows no provider URL and implements no provider protocol.
`apps/gridcraft/src/nextcloud.rs` is gone. That is the point. It was an app
speaking a provider's protocol and holding its credential on disk.

File › Open grew a Cloud section with My files and Shared with me side by side.
My files browses folders with a breadcrumb. Shared with me is flat, since an
incoming share has no common parent folder. Each row names who shared it and
whether it is read only.

Filtering uses the extension and the MIME together. A WebDAV server often
returns `application/octet-stream` for a perfectly good `.xlsx`. A web export
often has the right type and no extension. Either test alone hides files the
user can see in their own file manager.

Save As grew a Where section: This PC, unchanged, or the cloud account by name.
Save writes back with the entry's etag as `if_match`.

**A conflict is never retried and never forced.** `Error::Conflict` means
somebody else saved while this user was editing. A modal names the file and says
nothing was written. It offers Reload, Save as a copy, or Keep editing.
Getting that wrong loses work. It is the one failure here that matters.

A real dialog, in `craft-ui` so every Craft app gets the same one. Multi-select
recipients with autocomplete and removable chips, view or edit per share, an
optional note, a notify box, the list of who already has access with a
withdrawal beside each.

Partial success is shown, never collapsed into one failure. Sharing with five people where one
address is wrong shares with four and names the one that failed, leaving that
recipient in the picker so a correction is one edit.

**Nothing blocks the frame.** egui redraws at frame rate and these calls are
synchronous. Each runs on a worker. The UI drains a channel. Reading a
20 MB workbook on the UI thread would freeze the window for seconds.

**Autocomplete is debounced and ordered.** 200 ms after the last keystroke,
with a sequence number per request so an answer the user has typed past is
dropped before it reaches the list.

**Capabilities gate the feature itself.** Shared with me is hidden when the
provider does not offer it, the notify box is disabled without mail, and the
share dialog will not open without sharing. Offering a control that fails on
use is worse than not offering it.
The share dialog asks where and what to call it. Only this side can turn an
open workbook into bytes, so it answers, reusing the same cloud::save_as that
File > Save As already uses.
The environment variables and the session file follow the service, which is
collabcraft now. CloudCraft was too close to several unrelated products to
search for.
The account badge goes where every office suite puts it, at the far right of
the title bar. craft-ui draws it. Every Craft app therefore gets the same one.
Without it there was no way into the cloud at all. Every control is gated on an
account being linked. A build with no badge therefore showed none of this work
and looked exactly like the stock application.

Drawing the badge starts nothing. The worker signs in before any job it is
given. It then blocks for minutes polling the browser. Beginning that from a
paint call opened a browser at launch and left every later job queued behind
it.

Three faults in the path this exposed, all of them older than the badge.

`open_browser` handed the URL to `cmd` unquoted. `cmd` splits its command line
on `&`. An authorize URL is mostly `&`. The browser therefore received the
query up to the first one. The identity provider then reported that the client
identifier was missing. By that point it was. The URL is quoted now. The quotes go
through a raw command line, where they survive argument escaping.

A sign-in that failed could not be retried. The latch that stops a frame asking
twice was never cleared. The second press was swallowed.

The share dialog asks what the provider can do until it gets an answer, then
keeps it. On a cold start that answer is that nobody is signed in. Share stayed
unavailable for the rest of the run however many times somebody signed in. It is told to ask again when the account changes. It is told again when
storage finishes linking.

The waiting panel gained Open in browser and Copy link. A bare label cannot be
selected. When the browser does the wrong thing, the only way out is carrying
the address across by hand.

craft-libs is reached by a pinned revision now. That is how this repository
already reaches craft-print and craft-ui. A sibling path only resolves where
somebody has checked craft-libs out beside this one.
craft-libs was reviewed down to an interface and some controls. The provider,
the client for the service and the relay client all moved to the repository
that calls them. `craft-ui` lost its `collabcraft` feature with them.

So the share backend comes from its own crate now. `craft-ui` draws the dialog
and states each piece of work as a request. Nothing in craft-libs answers one.
`collabcraft-share` is the half that opens a socket. It lives beside the
client.

Nothing about what this application does has changed. The contracts and the
controls come from craft-libs as before. Only the three crates that reach a
wire come from somewhere else.
Four controls existed in craft-ui. No application drew one. This draws them.

The title bar shows who else is in the workbook, beside the account. Nothing
appears while a person works alone. The relay sends a roster with everybody in
it, the reader included. The row drops itself by asking the session which peer
it is. A badge colour is derived from the peer identity. Its position in the
list would do as well until somebody arrives or leaves. A cursor drawn by some
later change has to reach the same colour for the same person.

Share is a split button now. The label opens the share dialog, the common case.
The chevron holds working together and the three Save a Copy formats that used
to sit loose in the title bar.

Each cloud document in the Open page carries an overflow menu. A click on the
row still opens the document. Sharing and talking about it are rarer. Those sit
behind the ellipsis. A folder has no menu. Opening one is the only thing to do
with it.

Work together asks the provider where to go, then opens what it names. Only a
provider that reports the capability offers it at all. A workbook that has
never been in the cloud has no handle to key a room on. That one is told to
save there first.

The session file now says which account it belongs to. A token identifies
nothing on its own. The suite launcher keeps one keychain entry per account,
keyed on the service and the subject the provider issued. Both are written
beside the token once the service has answered.

Three tests cover the new job. The conversation a provider names gets opened. A
local workbook is told to save first. A provider with nowhere to talk never
offers the item. A fourth test writes a render of each state to the directory
in GRIDCRAFT_CLOUD_RENDERS, for a person to look at.

The libraries moved under this. A share carries the name its provider gave the
recipient. The access list reads as people now. The client grew a failure for a
request it could not encode.
craft_ui::cloud now draws the Open page, the Save As destination, the
account badge, the presence row, a document's overflow menu and the Share
split button. crates/ui-egui/src/cloud.rs goes from 1328 lines to 243.

What is left is what only GridCraft can answer. A document is a workbook.
These nine extensions and these seven content types are what it opens.
Bytes become one through file.open and go back through save_bytes. A
refused save sends the user to GridCraft's own Save As page. The words are
the ones i18n has. That is impl CloudApp for SheetApp, twenty-three methods
of a line or two each.

Services::cloud is gone. A backend is connected to the pages now, the way
the share dialog's already was. The worker is unchanged apart from where it
posts its answers. It holds the Post the job arrived with. Nothing outside
it can reach the queue.

SheetApp::account is gone too. The account badge is the pages' own control.
SheetApp::presence stayed, since the collab roster sets it from main.

All fifteen behaviour tests in crates/ui-egui/tests/cloud_files.rs pass with
every assertion byte-identical. Two things in the harness changed. Both are
the seam itself. The fake service implements CloudBackend now. It is
connected with app.cloud.connect, where it used to arrive through Services.

Nothing a user sees changed. The render test still writes the same five
images.
The pin named a branch head. That branch is in main now.
A workbook opened from the cloud went back as XLSX whatever it arrived as.
`encode` hardcoded `workbook.xlsx`. That was the only thing the pages told it.
Open `figures.csv` from the cloud, edit a cell, save. The service then holds
XLSX bytes under the name `figures.csv`. Download it and nothing opens it.
Nothing ever said so.

The pages hand over the destination now. The name decides, through the same
`save_bytes` the desktop Save As uses. That refuses an extension this
application cannot write and names the ones it can. A `.xls` from the cloud now
says to Save As `.xlsx`. It used to become one quietly.

A provider that issued no extension still says what the thing is. A file
exported by a web application commonly has a correct content type and a bare
name. The fixture in these tests is exactly that. The content type decides for
the four formats a bare name can mean here. Everything else this application
opens is a package format whose writer wants a real name. Letting `save_bytes`
refuse is more use than a guess.
The browser build connects the shared cloud pages and share dialog to craft-cloud-client on the same origin, so session cookies reach the API. Desktop-only device-flow polling stays out of wasm; unauthenticated users go to the service login page.
Move AutoSave into the existing More menu below 560 px so cloud account, presence, and Share controls remain reachable without overlap.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant