Skip to content

feat(files): let the user set a file's type from the header dropdown - #6373

Open
mzxchandra wants to merge 10 commits into
stagingfrom
feat/files-set-file-type
Open

feat(files): let the user set a file's type from the header dropdown#6373
mzxchandra wants to merge 10 commits into
stagingfrom
feat/files-set-file-type

Conversation

@mzxchandra

@mzxchandra mzxchandra commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

handleCreateFile was hardcoded to markdown, and there was no way to say "actually this is a CSV" afterwards. The only escape hatch was Rename - which never touched contentType, so untitled.md renamed to data.csv stayed text/markdown and kept opening in the rich markdown editor.

Feature

  • The filename breadcrumb dropdown gains a Type submenu (Document › / Code ›). Picking a type swaps the file's extension and its stored contentType in one write. Bytes are never touched, so it's a metadata edit, not a conversion - only text-editable types are offered, and the entry is absent entirely on a PDF or an image.
  • New registry at apps/sim/lib/uploads/utils/text-file-types.ts: 9 document types plus 20 code types. Every MIME is taken from the existing EXTENSION_TO_MIME rather than invented. It's framework-free, so the contract, route, hook, and component all consume one list. Two invariant tests keep it honest - every entry must round-trip through getMimeTypeFromExtension, and every entry must resolve to text-editable in resolveFileCategory, so nobody can add a type the viewer refuses to open.
  • The rename contract gained an optional contentType, allowlisted and cross-checked against the name's extension. The check derives extension → MIME (extension is the registry's unique key; several types share a MIME), and the server re-derives it rather than trusting the client.

Correctness fixes found while building it

  • updateWorkspaceFileContent read the row before taking the FOR UPDATE lock, then wrote that read's contentType back inside it. A content save overlapping a retype therefore resurrected the pre-retype type, leaving a file named .txt still stored as text/markdown. It now writes the column only when a caller actually declares one. This was latent before this PR too - a save racing a plain rename hit the same path.
  • Both type groups are nested behind their own submenu. A flat document list plus a Code › entry overflows the 240px menu cap, which pushed the entry gating every code type out of sight on open.

Realtime flush (partially verified - see Verification Results)

  • Changing a collaborative markdown file's type unmounts its editor and mounts one that reads the file's durable bytes. The relay owns durability for that document and persists on a 5s debounce, so the read raced the write and returned pre-edit content. The client cannot close this itself: its save path is disabled for a collaborative doc by design, and isDirty is pinned false.
  • Adds a FLUSH / FLUSH_COMPLETE round trip. flushPersist grows a mode and returns an outcome: only a debounced flush may be coalesced away by the cross-task dedup window, because a deduped no-op acked as success would ship exactly the staleness this closes. The client wait is bounded well under the persist budget, and a lapsed wait proceeds with the rename rather than blocking the user.
  • The published flush is stable and resolves the provider at call time. Binding it to provider identity republished on every socket churn, and a churn ending on null silently left the header with nothing to call.
  • Found in review: the persist path is a versioned object swap - every content write mints a new storage key and deletes the previous blob. So a confirmed flush left the cached list record pointing at a key that 404s, and the retype swapped editors onto it. The retype now awaits a list refetch between a persisted flush and the rename (useRefreshWorkspaceFiles, refetchType: 'all' - the default active resolves instantly against an unobserved list). The refresh rejects on a failed refetch rather than reporting success on the stale cache; the caller logs and proceeds, since the edits are already durable and aborting would lose an explicit user action over a transient fetch.

Test Coverage

CODE PATHS                                              TESTS
[+] lib/uploads/utils/text-file-types.ts                text-file-types.test.ts (80)
  ├── [★★★] withFileTypeExtension                       multi-dot, no-ext, .gitignore, trailing dot, case
  ├── [★★★] resolveTextFileType                         extension-first over shared MIME, stale MIME, null
  └── [★★★] registry invariants                         ext↔MIME round-trip, all text-editable, unique keys
[+] lib/api/contracts/workspace-files.ts                route.test.ts (9)
  └── [★★★] contentType allowlist + ext/MIME agreement  .exe rejected, mismatch rejected, .yml alias rejected
[+] lib/uploads/.../workspace-file-manager.ts           workspace-file-retype.test.ts (7)
  ├── [★★★] retype writes both columns                  + never touches contentUpdatedAt
  ├── [★★★] conflict probe skipped when name unchanged
  └── [★★★] no-op when neither name nor type changes
[+] updateWorkspaceFileContent contentType race         workspace-file-storage-accounting.test.ts (+2)
[+] app/api/.../[fileId]/route.ts PATCH                 route.test.ts - 401, 403, 409, event split
[+] hooks/queries/workspace-files.ts                    workspace-files.test.tsx (+7) - optimistic patch, rollback,
                                                          refresh awaits refetch / rejects on failure
[+] files/untitled-title.ts                             untitled-title.test.ts (25)
[+] realtime/handlers/file-doc.ts FLUSH                 file-doc.test.ts (+10) - persisted/unchanged/conflict,
                                                          debounce cancel, unjoined file, dedup bound
[+] collaboration/file-doc-provider.ts flush()          file-doc-provider.test.ts (+6) - ack, timeout, destroy
[+] collaboration/file-doc-room-context.tsx             file-doc-room-context.test.tsx (5) - churn regression

GAPS
  [GAP] files.tsx handleChangeFileType / formatFileType - no test harness for a 2.2k-line client component
  [GAP] resource-header.tsx submenu rendering          - untested; covered indirectly by browser QA
  [GAP] orchestration audit-description branch         - mocked at the boundary in route.test.ts

COVERAGE: 13/16 paths tested (81%)   QUALITY: ★★★ 13

Tests: 4 new test files, 6 extended. apps/sim 20,545 passed / 0 failed. apps/realtime 283 passed / 0 failed.

Pre-Landing Review

2 issues (1 critical, 1 informational).

  • [FIXED] [P2] (confidence 9/10) apps/realtime/src/handlers/file-doc.ts - a client-requested flush was unbounded. requested deliberately bypasses the dedup window, but room.edited is set on the first edit and never cleared, so every repeat performed a full projection: a Yjs→markdown conversion, a fresh blob upload, and a delete of the previous key. Fixed by pairing a monotonic edit counter with the sequence the last successful persist covered; a flush with nothing new to write now acks unchanged. The sequence is captured before the projection and stored only on success, so an edit arriving mid-write stays pending and a conflict is never mistaken for a completed write.
  • [SKIPPED] [INFO] (confidence 7/10) file-doc-room-context.tsx - fileId: '' sentinel on the no-provider fallback. The only consumer branches on status, so it is harmless today.

Specialist subagents were not dispatched; this is the checklist pass run inline.

Design Review

Frontend files changed. Reviewed inline against the emcn consumer rules: the Type entry is props-driven through the existing BreadcrumbItem.dropdownItems contract, the new DropdownSubmenuOption is a discriminated sibling so every existing consumer compiles unchanged, and no className overrides chrome. DropdownMenuSubTrigger supplies its own chevron and DropdownMenuRadioItem reserves its own indicator gutter, so neither is re-specified.

No full /design-review visual audit was run.

Eval Results

No prompt-related files changed - evals skipped.

Scope Drift

Scope Check: DRIFT DETECTED (minor)
Intent:    Let the user change a file's type from the filename dropdown after creating it.
Delivered: That, plus a contentType race fix and a realtime flush.
  • The contentType race fix is not optional - without it the relay's persist reverts the type you just picked.
  • The realtime flush was a directed follow-up after the stale-first-paint bug surfaced.

No unrelated files remain. A stray .gitignore line (.gstack/, a local tooling artifact from QA) was swept in by a broad stage and has been removed in e278ae38 - .gitignore now matches staging exactly.

Plan Completion

12 DONE, 3 CHANGED, 1 PARTIAL, 0 NOT DONE.

The three CHANGED items: both type groups nested rather than one inline (the 240px menu cap); manager coverage landed in a new workspace-file-retype.test.ts rather than extending workspace-file-manager.test.ts (no DB harness there); and the registry↔viewer invariant lives in text-file-types.test.ts rather than file-category.test.ts, which mocks the dependencies that invariant needs real.

Verification Results

Browser QA against local dev - 8 of 11 cases verified:

  • New file → untitled.md, rich editor, Type column "Markdown"
  • Type submenu present, current type checked, Document › / Code › groups
  • Markdown → JSON: name, editor swap to Monaco, Type column, icon
  • Code → Python → untitled.py
  • Back to Markdown restores the rich editor
  • Collision → (1) suffix, no 409
  • PDF correctly has no Type entry
  • Name and content_type agree in the database after every change

The stale first paint had a root cause, and it is fixed. It was not only the flush budget: a confirmed persisted flush rotates the storage key and deletes the old blob, so the viewer that mounted next read a dead key. Surfaced by Cursor in review and fixed in e1775025 / 2c1518ed (see the flush section above). Durability was already measurably improved before that - a fresh file's stored bytes went from size 0 to the typed content as part of the retype - and the relay logged Requested flush … persisted { edited: true }, so the round trip works end to end. The happy path has still not been re-run in a browser since the fix; the reviewer should treat that as the one open verification item.

/api/internal/file-doc/persist costs 3.6s cold vs 8ms warm in dev, so in a dev session the first retype after a server start will reliably lapse that budget. Worth a second look at whether 2s is the right number.

Also not verified: multi-collaborator retype, read-only member gating, and realtime propagation to a second session.

Notes for the reviewer

  • Pre-existing on staging: apps/realtime/src/handlers/file-doc.test.ts has 3 failing seed assertions on a clean origin/staging checkout (expected '' to contain '# Seeded'). They pass on this branch. Worth a look from whoever owns that file - a test that flips based on what else is in the file is fragile.
  • Pre-existing, out of scope: downloads serve the original upload filename and MIME. app/api/files/serve/[...path]/route.ts derives both from the storage key, which is frozen at upload and never rewritten by renameWorkspaceFile; the client then lets that Content-Disposition win over the DB record. Renaming already breaks this today. Retyping to a non-markdown type moves files onto that path, so it becomes more visible.
  • Docs: apps/docs/content/docs/en/files/ has an editor.mdx that covers the markdown editor. This feature likely warrants a mention there, but the docs carry a specific voice and screenshots, so that is left to the docs owner rather than done as a side effect.

Test plan

  • apps/sim - 20,544 passed, 0 failed, 25 skipped
  • apps/realtime - 283 passed, 0 failed
  • packages/realtime-protocol - 14 passed
  • bun run check:api-validation passes
  • bunx tsc --noEmit clean in apps/sim and apps/realtime
  • Biome clean on every changed file
  • Flush first-paint on a quiet machine
  • Multi-collaborator retype
  • Read-only member sees no Type entry

🤖 Generated with Claude Code

The file-detail filename dropdown gains a Type submenu offering the text-editable
types (nine document formats plus a nested Code group). Picking one swaps the
file's extension and its stored contentType in a single write, leaving the bytes
untouched, so a file created as untitled.md can become untitled.json and open in
the right editor.

Renaming previously never touched contentType, so name and type could silently
diverge; the retype path keeps them in agreement and the server re-derives the
pairing rather than trusting the client.
updateWorkspaceFileContent read the row before taking the FOR UPDATE lock, then
wrote that read's contentType back inside the transaction. A save overlapping a
type change therefore restored the pre-change type, leaving the file named .txt
while still stored as text/markdown.

A content write carries no opinion about the file's type unless the caller says
so, so the column is now only written when a contentType is supplied. The
live-doc markdown gate reads the committed row for the same reason.
Changing a collaborative markdown file's type unmounts its editor and mounts one
that reads the file's durable bytes. The relay owns durability for that document
and persists on a 5s debounce, so the read raced the write and returned the
content from before the last edits. The client cannot close this itself: its save
path is disabled for a collaborative doc by design, and isDirty is pinned false.

Adds a FLUSH/FLUSH_COMPLETE round trip so the client can ask the relay to project
the document now and wait for the answer. flushPersist grows a mode and returns
an outcome: only a debounced flush may be coalesced away by the cross-task dedup
window, because a deduped no-op acked as success would ship exactly the staleness
this closes. The client wait is bounded well under the persist budget and a
lapsed wait proceeds with the rename rather than blocking the user.
The published flush was bound to the provider's identity, so every socket churn
republished it — and a churn ending on null left the file-detail header with
nothing to call, silently degrading a retype back to a stale read. It now
publishes once and resolves the provider at call time.

Adds a log on both sides of the flush. Its outcome decides whether the caller may
treat the durable bytes as current, and unchanged/skipped are both silent
no-writes, so a stale read after a retype is otherwise indistinguishable from a
rendering bug.
…tten

A requested flush deliberately bypasses the cross-task dedup window, because a
deduped no-op acked as success would reintroduce the staleness the flush exists
to prevent. But room.edited is set on the first edit and never cleared, so every
repeat still performed a full projection: a Yjs-to-markdown conversion, a fresh
blob upload, and a delete of the previous key. A client emitting flush in a loop
could drive that unbounded.

Pairs a monotonic edit counter with the sequence the last successful persist
covered, so a flush with nothing new to write acks unchanged instead. The
sequence is captured before the projection and stored only on success, so an
edit arriving mid-write stays pending and a conflict is never mistaken for a
completed write.
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 7, 2026 8:48am

Request Review

@cursor

cursor Bot commented Aug 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches collaborative persistence, storage key rotation, and metadata writes across app, realtime, and workspace file manager—race-sensitive but heavily tested; user-facing retype still proceeds when flush times out, which can cause a brief stale read.

Overview
Users can change a text-editable workspace file’s type from the file-detail breadcrumb dropdown (Type with Document/Code submenus). The chosen type updates the filename extension and stored contentType in one PATCH without rewriting bytes; only types from the new text-file-types registry are allowed, with server-side extension/MIME pairing.

Collaborative markdown edits are no longer lost when retyping: the client calls a new FLUSH / FLUSH_COMPLETE realtime round trip so the relay projects the live Yjs doc before the editor unmounts. flushPersist returns truthful outcomes (persisted / unchanged / skipped), skips cross-task dedup for requested flushes, and uses editSeq / persistedEditSeq so repeat flushes don’t mint redundant blob versions. The editor publishes a stable flush via ref/context for the header.

Correctness fixes: content saves no longer overwrite contentType from a stale pre-lock read (fixes retype vs save races); rename/retype keeps contentUpdatedAt untouched; optimistic rename cache patches type with name; useRefreshWorkspaceFiles refetches storage keys after a successful flush so the next viewer doesn’t 404 the old blob key.

UI plumbing: ResourceHeader supports radio submenu dropdown items; untitled-name detection and uniqueFileName generalize beyond .md.

Reviewed by Cursor Bugbot for commit 2c1518e. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR lets users change a text file’s type from the file-header dropdown while keeping its extension and stored MIME type aligned. It also coordinates collaborative-document persistence before retyping and avoids stale contentType writes during concurrent content saves.

  • Adds a shared registry of supported document and code file types.
  • Extends rename validation and persistence to update filename and content type together.
  • Adds a bounded realtime flush protocol and refreshes rotated storage metadata before switching editors.
  • Adds focused coverage for type resolution, retyping, optimistic updates, persistence races, and collaboration flushing.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains, and the previously reported relative-import violation is fixed at current HEAD.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/files/files.tsx Orchestrates type selection, collaborative flush, metadata refresh, and the rename mutation.
apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts Updates filename and content type together while preserving content metadata unless explicitly supplied.
apps/realtime/src/handlers/file-doc.ts Adds requested flush handling with truthful outcomes and edit-sequence deduplication.
packages/realtime-protocol/src/file-doc.ts Defines the shared FLUSH and FLUSH_COMPLETE wire contract.
apps/sim/lib/uploads/utils/text-file-types.ts Centralizes selectable text-file types and filename-extension resolution.
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts Publishes the collaboration flush callback and now satisfies the previously reported absolute-import convention.

Sequence Diagram

sequenceDiagram
  participant U as User
  participant UI as File Header
  participant RT as Realtime Service
  participant API as Workspace Files API
  participant DB as Database/Object Storage

  U->>UI: Select new text file type
  UI->>RT: FLUSH collaborative document
  RT->>DB: Persist pending edits
  RT-->>UI: FLUSH_COMPLETE
  opt Persist rotated storage key
    UI->>API: Refresh workspace files
    API->>DB: Read current file metadata
    API-->>UI: Fresh storage metadata
  end
  UI->>API: PATCH name + contentType
  API->>API: Validate extension/MIME agreement
  API->>DB: Update file metadata atomically
  API-->>UI: Updated file
  UI->>UI: Mount editor for new type
Loading

Reviews (4): Last reviewed commit: "fix(files): surface a failed pre-retype ..." | Re-trigger Greptile

Comment thread apps/sim/app/workspace/[workspaceId]/files/files.tsx
The .gstack/ entry was created by local tooling during QA on this branch and got
swept in by a broad stage. It is unrelated to the file-type work and the repo
references .gstack nowhere, so it does not belong in this PR.
… live key

A collaborative flush persists through a versioned object swap: it mints a new
storage key and deletes the previous blob. The retype then swaps editors from
the optimistic rename patch, so the newly mounted viewer read `key` off a record
the flush had already invalidated - a 404, or pre-edit text from the content
cache keyed on that dead key, until the rename's own invalidation landed.

Awaits a list refetch between a confirmed `persisted` flush and the rename.
`refetchType: 'all'`, because the caller awaits this for a usable key and the
default `active` resolves immediately against an unobserved list.

Also moves this file's two sibling imports onto the `@/` alias per the repo's
absolute-import rule.
@mzxchandra

Copy link
Copy Markdown
Contributor Author

@greptile

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@cursor review

Comment thread apps/sim/hooks/queries/workspace-files.ts
…owing it

`invalidateQueries` resolves whether or not the refetch succeeded, so the
refresh reported success while leaving the dead storage key in the cache - the
caller awaiting a usable key could not tell the two apart.

The hook now rejects on a failed refetch. The retype logs and proceeds rather
than aborting: the edits are already durable, the type change is explicit, and
the rename's own invalidation refetches straight after, so the cost of a failed
refresh is one stale first paint - the pre-fix behaviour - not a lost change.
@mzxchandra

Copy link
Copy Markdown
Contributor Author

@greptile

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 2c1518e. Configure here.

@mzxchandra
mzxchandra marked this pull request as ready for review August 7, 2026 09:21
@mzxchandra

Copy link
Copy Markdown
Contributor Author

@waleedlatif1 this touches file realtime

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