From 2965a782676154b3e9c61ae2ef20a7f6b5393caf Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 13 Apr 2026 00:43:03 -0700 Subject: [PATCH 01/16] docs(desktop): add v2 file editor audit + implementation plans --- .../20260412-file-editor-v2-feature-audit.md | 301 +++++++ .../20260412-file-editor-v2-implementation.md | 744 ++++++++++++++++++ 2 files changed, 1045 insertions(+) create mode 100644 apps/desktop/plans/20260412-file-editor-v2-feature-audit.md create mode 100644 apps/desktop/plans/20260412-file-editor-v2-implementation.md diff --git a/apps/desktop/plans/20260412-file-editor-v2-feature-audit.md b/apps/desktop/plans/20260412-file-editor-v2-feature-audit.md new file mode 100644 index 00000000000..7a29e9c0b80 --- /dev/null +++ b/apps/desktop/plans/20260412-file-editor-v2-feature-audit.md @@ -0,0 +1,301 @@ +# File Editor v2 β€” Feature Audit & Rebuild Checklist + +## How to use this doc + +Living checklist for porting v1's file-editor feature set into v2 and rebuilding it to be better along the way. Each item is tagged: + +- `[x]` already working in v2 β€” verify by opening the cited v2 path +- `[ ]` not yet in v2 β€” open the cited v1 path as a reference when porting +- `[~]` partial in v2 (stubbed, TODO'd in code, or shared with v1) β€” needs finishing or cleanup +- πŸ’‘ intentional improvement over v1 (v1 does not have this) + +Mark items off as we ship them. Keep v1 code untouched per the V1β†’V2 duplicate rule β€” all work lands under `v2-workspace/$workspaceId/`. + +## Where things live + +**v1 editor:** `apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/ContentView/TabsContent/TabView/FileViewerPane/`. CodeMirror 6 surface (`.../WorkspaceView/components/CodeEditor/CodeEditor.tsx`) plus a coordinator layer (`WorkspaceView/state/editorCoordinator.ts`, `editorBufferRegistry.ts`, `useEditorDocumentsStore`, `useEditorSessionsStore`) handling buffers, dirty state, revisions, conflicts, and session-to-pane binding. + +**v2 editor:** `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/`. `FilePane.tsx` routes to `renderers/CodeRenderer`, `renderers/MarkdownRenderer`, or `renderers/ImageRenderer`. Read/write goes through `renderer/hooks/host-service/useFileDocument`. Pane state lives in the v2 pane registry + Zustand workspace store, not v1's editor coordinator. + +**Architectural flag:** `CodeRenderer.tsx:2` and `MarkdownRenderer.tsx:3` import `CodeEditor` directly from the v1 path. Decoupling this is item 13.1 below. + +--- + +## 0. Core architecture: file-type registry + +The rebuild centers on a small registry rather than the hardcoded `if (isImage) ... else if (isMarkdown) ...` branching that `FilePane.tsx` currently does. This section documents the shape we're building toward β€” everything below is evaluated against it. + +### Shape + +```ts +type FileHandler = { + id: string; // "markdown" | "image" | "csv" | "code" + match: (filePath: string, meta: FileMeta) => boolean; + documentKind: "text" | "bytes" | "custom"; + views: FileView[]; // ordered by priority; default is views[0] + defaultViewId?: string; +}; + +type FileView = { + id: string; // "code" | "preview" | "grid" + label: string; // "Markdown" | "Preview" | "Grid" + Renderer: ComponentType; + search?: SearchAdapterFactory; // per-view search implementation +}; +``` + +### Rules + +1. **One pane, swap renderer.** All views on a handler render inside the *same* `FilePane` container. Toggling a view swaps the React component; it does **not** open a new tab or pane. This matches Cursor's behavior (one tab, header toggle, shared scroll position). +2. **Shared document across views.** Every view on a given URI subscribes to one reference-counted `useFileDocument` handle. Dirty state, revision, external-change detection, and save flow are shared β€” swapping views or opening the same file in a split keeps edits in sync. +3. **Hide the toggle when there's only one view.** If `handler.views.length === 1`, the pane header shows no mode-toggle UI at all. This is one of our three design decisions (see below). +4. **Search is per-view.** Each view registers its own `SearchAdapterFactory`. The find widget UI is shared (Cursor-style, see section 5); the matching implementation is delegated to whichever view is active. +5. **`documentKind: "custom"` is the escape hatch.** Views that need their own state model (future: Jupyter notebooks, SQLite explorer, hex editor) opt out of the shared-text-document machinery and manage their own document. 90% of file types go through `"text"`. + +### Three concrete design decisions + +1. **Markdown defaults to `code` view, not `preview`.** `MarkdownRenderer.tsx:23` currently hardcodes `"rendered"`; flip the default and make `"preview"` the secondary option. Label the code view "Markdown" (matching Cursor) rather than "Raw". +2. **Mode toggle only renders when `views.length > 1`.** Code-only file types get no header chrome clutter. Markdown gets a toggle. Future CSV/JSON-form get toggles. +3. **Cursor-style find widget + per-view search adapters.** One shared find component at the top of the pane (case-sensitive, whole-word, regex, match-count, up/down nav, close). The adapter interface (`open`, `setQuery`, `next`, `previous`, `close`, flags) is implemented per-view: CodeMirror uses its native search state; TipTap preview does DOM-range search; grid view (future) highlights matching cells. + +### Initial handlers + +| Handler | `match` | `documentKind` | Views | Toggle? | +|---|---|---|---|---| +| `image` | `isImageFile` | `"bytes"` | `image` | no | +| `markdown` | `isMarkdownFile` | `"text"` | `code` (default, "Markdown"), `preview` ("Preview") | yes | +| `binary` | content probe | `"bytes"` | `warning-then-code` | no | +| `code` | fallback | `"text"` | `code` | no | + +### Future handlers (design must scale to these) + +| Handler | Why it fits | Notes | +|---|---|---| +| `csv` / `tsv` | `documentKind: "text"`; `grid` (default) + `source` views share the same text buffer | Grid virtualizes rows; source is CodeMirror with CSV highlighting. Large-file fallback reuses the `too-large` view on the `code` handler. | +| `json-form` | `documentKind: "text"`; `form` + `source` views | Specific filename matchers for `package.json`, `tsconfig.json`, etc. can layer structured forms over raw JSON. | +| `env` | `documentKind: "text"`; `form` + `source` views | Key/value form UI for `.env` files. | +| `notebook` / `.ipynb` | `documentKind: "custom"` | Cell-based model, not a flat string. Owns its own state and save path. | +| `sqlite` | `documentKind: "custom"` | Query UI, schema explorer, result grid. | +| `pdf` | `documentKind: "bytes"` | Read-only viewer. | +| `hex` | `documentKind: "custom"` | Cross-file alternative view for binary content. | + +### Grounded in VS Code's editor architecture + +VS Code (verified against `/tmp/vscode-research/vscode/src/vs/workbench/services/editor/common/editorResolverService.ts`) uses `IEditorResolverService.registerEditor(glob, info, options, factory)` with priority levels `builtin | option | default | exclusive` (`RegisteredEditorPriority` at lines 64–69). Each registration produces a separate `EditorInput` that opens in its own pane β€” i.e., VS Code does **not** have an in-pane mode toggle. Cursor layered a header affordance on top that swaps renderers inside one pane while keeping the same underlying text model. + +We're taking two concrete things from VS Code: +- **Reference-counted shared text model.** VS Code's `textModelResolverService.createModelReference(resource)` (`customTextEditorModel.ts:30`) hands out refcounted references to a singleton `ITextModel` per URI. That's how source + preview stay in sync. Our equivalent: `useFileDocument` becomes keyed by absolute path with a refcount, so two views or a split share one buffer. +- **User override setting.** VS Code's `workbench.editorAssociations` setting (`editorResolverService.ts:37`) is a glob β†’ default-view map. Our equivalent is a setting that lets a user say "always open `.md` in Preview" without changing the handler's default. + +We're **not** taking: +- VS Code's pane-per-EditorInput model. Views are renderer components inside one pane, not separate panes. +- `Reopen Editor With…` as a launch feature. Nice to have later for switching handlers entirely (e.g., `.json` β†’ form editor); not needed for launch. + +--- + +## 1. Editor surface (CodeMirror) + +- [~] CodeMirror 6 with line numbers, history, bracket matching, multi-cursor, indent-on-input, line wrapping, drop cursor, selection-match highlight β€” *currently the v1 `CodeEditor.tsx` is imported into v2; duplicate it into v2* +- [x] Syntax highlighting (~25 languages via `loadLanguageSupport.ts`) +- [x] Cmd+S save keymap +- [x] Theme + font reactivity (`createCodeMirrorTheme`, `useResolvedTheme`) +- [ ] Word wrap toggle πŸ’‘ (v1 always-on) +- [ ] Tab width / indent size setting πŸ’‘ +- [ ] Read-only compartment for non-editable contexts (v1: `editableCompartment`) + +## 2. Save / dirty state / conflicts + +- [x] Save via `useFileDocument` β†’ `filesystem.writeFile` with `ifMatch` revision precondition +- [x] Dirty dot in tab title β€” `usePaneRegistry.tsx:131` +- [x] External disk change detection via `fs:events` subscription (auto-reload when clean) +- [~] **Save conflict resolution dialog** β€” v1 ships `FileSaveConflictDialog` (Reload / Review Diff / Overwrite). v2 `useFileDocument` populates `conflict.diskContent` but `FilePane` never renders it. Port required. + - v1: `.../FileViewerPane/components/FileSaveConflictDialog/` +- [~] **Close-pane save guard** β€” `usePaneRegistry.tsx:154` "Save" button is a `// TODO: wire up save via editor ref` no-op. Needs a document handle. +- [ ] Discard / revert a dirty buffer (no hotkey, no menu in v2) +- [ ] Multi-file sequenced save when a tab with multiple dirty panes closes + - v1: `editorCoordinator.saveAndClosePendingTab` +- [ ] Document buffer registry equivalent so a file open in two panes shares state + - v1: `WorkspaceView/state/editorBufferRegistry.ts` +- [ ] External rename tracking β€” panes update their path and preserve dirty state + - v1: `FileViewerPane.pendingRenamePathRef` + +## 3. View modes (via the file-type registry β€” see section 0) + +Diff is **not** a view on the `code` or `markdown` handlers in v2 β€” it stays as its own pane kind (`DiffPane`, already in v2). This is a simplification over v1's three-way `raw | rendered | diff` toggle. + +- [ ] Build the `FileHandler` / `FileView` registry described in section 0 +- [ ] `FilePane.tsx` dispatches via the registry instead of hardcoded `isMarkdownFile`/`isImageFile` branches +- [ ] Pane header renders a segmented toggle only when `handler.views.length > 1` +- [~] Markdown handler registers `code` (default) + `preview` views + - Fix `MarkdownRenderer.tsx:23` β€” flip default from `"rendered"` to `"code"`, wire up `_setViewMode`, mount `MarkdownViewModeToggle` via the shared header toggle (not `renderHeaderExtras`) +- [ ] Code handler registers a single `code` view; no toggle shown +- [ ] Image handler registers a single `image` view; no toggle shown +- [ ] Binary handler registers a `warning-then-code` view that prompts before opening as text +- [ ] Mode-switch preserves the shared document β€” no remount, no dirty-state loss (v1: `requestViewModeChange` in `editorCoordinator`) +- [ ] User setting: per-glob default view override (VS Code's `workbench.editorAssociations` equivalent) πŸ’‘ + +## 4. Diff view (per-file) + +- [ ] Inline vs side-by-side toggle (v2 has this on the changes pane, not per-file) +- [ ] Hide unchanged regions toggle +- [ ] Auto-scroll to first changed line on diff open + - v1: `useScrollToFirstDiffChange` +- [ ] Diff scrollbar decorations + - v1: `DiffScrollbarDecorations` component +- [ ] Right-click "Edit at location" (see 3) + +## 5. Find / search + +Cursor-style: one shared find widget at the top of the pane with case-sensitive, whole-word, regex, match-count, up/down nav, close. Each view registers a `SearchAdapterFactory` (see section 0). Find UI is shared; matching logic is delegated. + +- [ ] Shared find widget component (`FilePaneFindBar`) rendered at the top of `FilePane` when search is open β€” case-sensitive, whole-word, regex toggles, match count, prev/next, close +- [ ] `SearchAdapter` interface: `open()`, `setQuery(q, flags)`, `next()`, `previous()`, `close()`, `matchCount`, `activeIndex` +- [ ] Thread `editorRef` through `CodeRenderer` so the `code` view's search adapter can call `openSearchPanel(view)` (currently broken β€” `editorRef` is not passed) +- [ ] `code` view adapter wraps CodeMirror's native search state +- [ ] `preview` view adapter does DOM-range text search over the TipTap container + - v1 reference: `MarkdownSearch` + `useMarkdownSearch` +- [ ] Diff pane adapter β€” lives on `DiffPane`, not the file-type registry + - v1 reference: `useDiffSearch` +- [x] CodeMirror's default Cmd+F keymap still works inside the code view (fallback) +- [ ] πŸ’‘ Project-wide find-in-files (v1 missing too) + +## 6. Tab / preview pane UX + +- [x] Preview pane (italic title when unpinned) β€” `usePaneRegistry.tsx:128` +- [x] Pin on header click β€” `onHeaderClick: ctx.actions.pin()` +- [ ] **Auto-pin on first edit** (v1: `pinPane` triggered by `dirty && !isPinned` in `FileViewerPane`) +- [ ] File-open-mode setting (preview vs always-new-tab) + - v1: `useFileOpenMode`, `settings.getFileOpenMode` +- [ ] Reopen-closed-tab hotkey (Cmd+Shift+R) + - v1: `REOPEN_TAB` in the hotkey registry +- [ ] Move pane to tab / move pane to new tab + +## 7. Split panes (within a tab) + +- [ ] Split horizontal / vertical / auto from file-pane toolbar +- [ ] Split with new chat / split with new browser +- [ ] Equalize splits +- [ ] Prev/Next pane keyboard nav (`Cmd+Shift+Left/Right` in v1) + +## 8. Context menu + +- [~] v2 only relabels "Close Pane" β†’ "Close File" at `usePaneRegistry.tsx:172`. Needs: +- [ ] Cut / Copy / Paste +- [ ] Copy Path +- [ ] Copy Path:Line (with selection range β€” v1: `useEditorActions.handleCopyPathWithLine`) +- [ ] Find +- [ ] Reveal in Files sidebar +- [ ] Open in External Editor (tRPC: `external.openFileInEditor`) +- [ ] Pane actions (split, move-to-tab, close) + +## 9. File pane toolbar / header + +- [ ] Filename / breadcrumb in pane body (v2 only shows filename in tab title) +- [ ] Pin / unpin button (v1: `FileViewerToolbar`) +- [ ] Mode toggle (segmented control) β€” shown only when the current handler has >1 view (see section 0) +- [ ] Save indicator + manual save button +- [ ] Diff sub-controls (inline/side-by-side, hide unchanged) β€” these live on `DiffPane`, not on the file-type registry + +## 10. Image / binary / special files + +- [x] Image viewer (`ImageRenderer.tsx`) up to 10 MB, base64 +- [x] Too-large / not-found / binary placeholders (`FilePane.tsx:55-82`) +- [ ] πŸ’‘ Zoom / pan / fit / actual-size controls +- [ ] πŸ’‘ Copy image to clipboard + +## 11. Settings affecting the editor + +- [x] Editor font family + size (`settings.getFontSettings`) +- [x] Theme (light/dark/system) +- [ ] File open mode (preview vs new tab) +- [ ] Markdown style preference passthrough +- [ ] πŸ’‘ Word wrap, tab width, render whitespace + +## 12. Hotkeys + +- [x] Cmd+S save +- [ ] Cmd+F find (wired in CodeMirror, but no surfaced button/action) +- [ ] Cmd+Shift+C copy-path-with-line +- [ ] Cmd+Shift+W close-tab with dirty guard +- [ ] Cmd+Shift+R reopen-closed-tab +- [ ] Prev/next tab, prev/next pane +- [ ] User-overridable hotkey table in settings (v1: `hotkeyOverridesStore`) + +## 13. v2-specific architectural cleanup + +- [ ] **13.1** Duplicate `CodeEditor.tsx` (and its `createCodeMirrorTheme`, `loadLanguageSupport.ts`, adapter) from v1 into `v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/CodeEditor/`. Update imports in `CodeRenderer.tsx` and `MarkdownRenderer.tsx`. Per `feedback_v1_v2_port_duplicate.md`: duplicate, do not share or delete v1. +- [ ] **13.2** Same treatment for `TipTapMarkdownRenderer` and any other v1 editor utilities pulled in by v2. +- [ ] **13.3** Build the `FileHandler` / `FileView` registry described in section 0. Ship with `code`, `markdown`, `image`, `binary` handlers. `FilePane.tsx` dispatches through the registry instead of hardcoded type branches. +- [ ] **13.4** Make `useFileDocument` reference-counted and keyed by absolute path, so multiple views on the same file (or a split) share one buffer. This is our equivalent of VS Code's `textModelResolverService.createModelReference`. +- [ ] **13.5** Build a v2-native equivalent of `editorCoordinator` / `editorBufferRegistry` / session store, scoped to the v2 pane registry, so multi-pane shared buffers, conflict resolution, rename tracking, and close-tab save sequencing all have a consistent home. Decide whether it layers on top of `useFileDocument` or folds buffer ownership in. +- [ ] **13.6** Thread `editorRef` (the `CodeEditorAdapter`) through `CodeRenderer` so the pane can call `openFind()`, `revealPosition()`, etc. from outside the editor β€” blocks find widget, close-pane save guard, go-to-line, and copy-path-with-line. + +## 14. Rebuild improvements (do better than v1) + +- πŸ’‘ Go-to-line command (Cmd+G) +- πŸ’‘ **Link detection / Cmd+click navigation** β€” cheapest LSP-adjacent feature. Parse the visible buffer for path-like strings (imports, markdown links, `file.ts:123:4` log patterns), underline on hover, Cmd+click to open via `openFilePane`. One CodeMirror decoration extension + a path-resolver utility. Should be structured as a built-in `LinkProvider` (see section 15) so future LSP providers plug into the same registry. Ship as the next PR after v2 launches. +- πŸ’‘ Inline AI edits / ghost text driven by the workspace chat session β€” v1 has zero editor ↔ AI integration +- πŸ’‘ Sticky scroll (current function header pinned to the top of the viewport) β€” CodeMirror community extensions exist +- πŸ’‘ Breadcrumb path in pane body, click segments to navigate + +--- + +## 15. LSP roadmap (post-launch, not on the implementation spec) + +Language features (diagnostics, hover, go-to-definition, completion, rename) are **explicitly out of scope for v2 launch**. This section documents the tiered path for adding them later so we can reason about it without committing. + +VS Code's architecture (verified at `editor/contrib/links/browser/links.ts:42` and `editor/common/languages.ts:1551`) is a unified `LanguageFeatureRegistry` pattern shared across `LinkProvider`, `DefinitionProvider`, `HoverProvider`, `CompletionProvider`, etc. Each file type / language ID can register N providers from N sources. If we build LSP, we mirror this registry pattern. + +### Tier 1 β€” Link detection (β‰ˆ1 day) + +Parse the buffer for link-like patterns. Zero language-server involvement. Covers ~70% of "go to related file" use cases. + +- Path strings in import statements (`import X from "./foo"`, `from .foo import X`, etc.) +- Markdown links (`[text](./other.md)`) +- Log-line references (`foo.ts:123:4`) +- Bare path strings in any language (`"./config.json"`) + +Implementation: one CodeMirror decoration extension that underlines matches on hover; Cmd+click resolves against the file's directory and opens via `openFilePane`. Register as a built-in `LinkProvider` in whatever feature-registry shape we pick, so tier 3 can add more providers without refactoring tier 1. + +### Tier 2 β€” Inline diagnostics without a server (β‰ˆ3–5 days) + +- Run `tsc --noEmit` / `eslint` / language-specific linters as subprocesses per save +- Parse output into diagnostics, feed into CodeMirror's `@codemirror/lint` extension +- Red squigglies + gutter markers + popover error details + +No protocol work. No hover docs, no completion, no go-to-definition β€” just diagnostics. Priority depends on user feedback after launch. + +### Tier 3 β€” Full LSP (β‰ˆ2–4 weeks, own design doc required) + +- Per-workspace language-server process manager (spawns tsserver, pyright, rust-analyzer, etc. on file-type open) +- CodeMirror LSP bridge (community clients: `codemirror-languageserver`, `@open-rpc/codemirror-lsp-client`) +- Hover tooltips, completion popups, go-to-definition (peek overlay or pane navigation), find references, rename symbol +- Config for which server handles which extension +- `LanguageFeatureRegistry` shared across all feature types so tier 1's link provider coexists with LSP-contributed providers + +Should be its own design doc with its own plan β€” not appended to this audit. Flagged here only so we don't accidentally design tier-1 in a way that blocks tier-3. + +### What we should NOT do + +- Don't try to ship tier 2 or 3 with v2 launch +- Don't build a custom LSP protocol layer (CodeMirror clients exist) +- Don't try to match VS Code's language feature completeness β€” pick the features that matter most for our users (likely: TypeScript + Python diagnostics, go-to-definition across files) + +--- + +## Key file paths + +**v1 reference:** +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/state/editorCoordinator.ts` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/state/editorBufferRegistry.ts` +- `.../FileViewerPane/components/FileSaveConflictDialog/` +- `.../FileViewerPane/hooks/{useFileSave,useFileContent,useDiffSearch,useMarkdownSearch}.ts` + +**v2 target:** +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx` +- `.../usePaneRegistry/components/FilePane/FilePane.tsx` +- `.../FilePane/renderers/{CodeRenderer,MarkdownRenderer,ImageRenderer}/` +- `.../FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx` +- `apps/desktop/src/renderer/hooks/host-service/useFileDocument.ts` diff --git a/apps/desktop/plans/20260412-file-editor-v2-implementation.md b/apps/desktop/plans/20260412-file-editor-v2-implementation.md new file mode 100644 index 00000000000..a9267f93030 --- /dev/null +++ b/apps/desktop/plans/20260412-file-editor-v2-implementation.md @@ -0,0 +1,744 @@ +# File Editor v2 β€” Implementation Spec + +Tactical reference for the rebuild. Design rationale lives in `20260412-file-editor-v2-feature-audit.md` section 0. This doc is what you read when you're about to write code. + +--- + +## 1. File layout + +``` +apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/ +β”œβ”€β”€ state/ +β”‚ └── fileDocumentStore/ +β”‚ β”œβ”€β”€ fileDocumentStore.ts β€” module Map, refcount, lifecycle +β”‚ β”œβ”€β”€ useSharedFileDocument.ts β€” React hook (acquire on mount, release on unmount) +β”‚ β”œβ”€β”€ types.ts β€” Document, ContentState, DocumentEvents +β”‚ └── index.ts +β”‚ +└── hooks/usePaneRegistry/components/FilePane/ + β”œβ”€β”€ FilePane.tsx β€” acquires document, resolves views, renders all chrome + active view + β”œβ”€β”€ FilePane.types.ts β€” FilePaneData (filePath, viewId, forceViewId) + β”œβ”€β”€ components/ + β”‚ β”œβ”€β”€ FileViewToggle/ β€” segmented control, RTL + β”‚ β”œβ”€β”€ LoadingState/ + β”‚ β”œβ”€β”€ ErrorState/ β€” not-found, too-large, is-directory + β”‚ β”œβ”€β”€ ExternalChangeBar/ + β”‚ β”œβ”€β”€ OrphanedBanner/ + β”‚ β”œβ”€β”€ SaveErrorBanner/ + β”‚ └── ConflictDialog/ + └── registry/ + β”œβ”€β”€ index.ts β€” resolveViews, ALL_VIEWS, orderForToggle + β”œβ”€β”€ types.ts β€” FileView, ViewProps, Priority, FileMeta + β”œβ”€β”€ resolveViews.ts + β”œβ”€β”€ allViews.ts β€” import list + └── views/ + β”œβ”€β”€ CodeView/ + β”‚ β”œβ”€β”€ CodeView.tsx + β”‚ β”œβ”€β”€ index.ts β€” exports FileView object + β”‚ └── components/ + β”‚ └── CodeEditor/ β€” duplicated from v1 in stage 1 + β”‚ β”œβ”€β”€ CodeEditor.tsx + β”‚ β”œβ”€β”€ createCodeMirrorTheme.ts + β”‚ β”œβ”€β”€ loadLanguageSupport.ts + β”‚ β”œβ”€β”€ CodeEditorAdapter.ts + β”‚ └── index.ts + β”œβ”€β”€ MarkdownPreviewView/ + β”‚ β”œβ”€β”€ MarkdownPreviewView.tsx + β”‚ β”œβ”€β”€ index.ts + β”‚ └── components/ + β”‚ └── MarkdownSearch/ β€” view-owned find UI (ported from v1) + β”œβ”€β”€ ImageView/ + β”‚ β”œβ”€β”€ ImageView.tsx + β”‚ └── index.ts + └── BinaryWarningView/ + β”œβ”€β”€ BinaryWarningView.tsx + └── index.ts + +# If a second view later needs CodeEditor (e.g., a CsvSourceView, HexRawView), +# promote it to registry/views/components/CodeEditor/ at that point β€” not preemptively. +``` + +--- + +## 2. Core types + +### 2.1 Registry + +```ts +// registry/types.ts + +export type FileMeta = { + size?: number; + isBinary?: boolean; +}; + +export type DocumentKind = "text" | "bytes" | "custom"; + +export type Priority = "builtin" | "option" | "default" | "exclusive"; + +export const PRIORITY_RANK: Record = { + exclusive: 5, + default: 4, + builtin: 3, + option: 1, +}; + +export type FileView = { + id: string; + label: string; + match: (filePath: string, meta: FileMeta) => boolean; + priority: Priority; + documentKind: DocumentKind; + Renderer: ComponentType; +}; + +export type ViewProps = { + document: SharedFileDocument; + filePath: string; + workspaceId: string; + onDirtyChange: (dirty: boolean) => void; +}; +``` + +### 2.2 Document + +```ts +// state/fileDocumentStore/types.ts + +export type ContentState = + | { kind: "loading" } + | { kind: "text"; value: string; revision: string } + | { kind: "bytes"; value: Uint8Array; revision: string } + | { kind: "not-found" } + | { kind: "too-large" } + | { kind: "is-directory" }; + +export type DocumentPhase = "loading" | "resolved" | "disposed"; + +export type SharedFileDocument = { + // Identity + readonly workspaceId: string; + readonly absolutePath: string; + + // Lifecycle + readonly phase: DocumentPhase; + readonly content: ContentState; + + // State flags (any combination may be true simultaneously) + readonly dirty: boolean; + readonly pendingSave: boolean; + readonly saveError: Error | null; + readonly conflict: ConflictState | null; + readonly orphaned: boolean; + readonly hasExternalChange: boolean; + + // Metadata (for view resolution) + readonly byteSize: number | null; + readonly isBinary: boolean | null; + + // Content mutations + setContent(next: string): void; + save(opts?: { force?: boolean }): Promise; + reload(): Promise; + discard(): Promise; + resolveConflict(choice: "reload" | "overwrite" | "keep"): Promise; + + // Subscription (React consumes via useSyncExternalStore) + subscribe(listener: () => void): () => void; + snapshot(): SharedFileDocument; +}; + +export type ConflictState = { + diskContent: string; + diskRevision: string; +}; + +export type SaveResult = + | { status: "saved" } + | { status: "conflict"; diskContent: string; diskRevision: string } + | { status: "error"; error: Error }; +``` + +### 2.3 Pane data + +```ts +// FilePane.types.ts + +export type FilePaneData = { + kind: "file"; + filePath: string; // absolute path + mode: "editor"; + hasChanges: boolean; // mirrored from document.dirty for tab indicator + viewId?: string; // user's toggle selection + forceViewId?: string; // escape hatch from BinaryWarningView "Open Anyway" +}; +``` + +--- + +## 3. Document state machine + +State flags are independent booleans. Multiple can be true at once. Transitions are driven by actions and external events. + +### 3.1 Flag combinations (the interesting ones) + +| Flags | Meaning | FilePane renders | +|---|---|---| +| `phase=loading` | initial load | `LoadingState` | +| `phase=resolved` + text content | normal | view mounted | +| `dirty` | user edited | dot in tab title | +| `pendingSave` | save in flight | subtle indicator; block close | +| `dirty` + `hasExternalChange` | user edited, disk also changed | `ExternalChangeBar` | +| `saveError` | last save failed (non-conflict) | `SaveErrorBanner` + view mounted | +| `conflict` | save failed with ETag mismatch | `ConflictDialog` (modal over view) | +| `orphaned` + `dirty` | file deleted externally, unsaved buffer preserved | `OrphanedBanner` + view mounted with buffer | +| `orphaned` + `!dirty` | file deleted externally, no edits | `OrphanedBanner` + view mounted with last content | +| `phase=resolved` + `not-found` | never existed (not deleted β€” new file from stale link) | `ErrorState reason=not-found` | +| `phase=resolved` + `too-large` | file exceeds read limit | `ErrorState reason=too-large` | + +### 3.2 Transitions + +``` +[loading] + ↓ readFile success +[resolved, content=text|bytes] + ↓ setContent(next) +[resolved, content, dirty] + ↓ save() +[resolved, content, dirty, pendingSave] + ↓ writeFile success ↓ writeFile ETag mismatch ↓ writeFile other error +[resolved, content] [resolved, content, dirty, [resolved, content, dirty, + conflict] saveError] + ↓ resolveConflict("overwrite") + [resolved, content, dirty, pendingSave] + ↓ resolveConflict("reload") + [resolved, content] + +From [resolved, anything]: + fs:events delete β†’ orphaned=true + fs:events rename β†’ update absolutePath, preserve state + fs:events update/create + dirty β†’ hasExternalChange=true + fs:events update/create + !dirty β†’ auto reload β†’ onDidResolve + fs:events overflow β†’ treat like update (re-check + reload) +``` + +### 3.3 Event handling (store-level) + +**Constraint: v2 FilePane code uses `@superset/workspace-client` exclusively. No `electronTrpc`.** That's v1's IPC path. The whole point of the v2 architecture is that workspaces talk to the host service directly, not through Electron IPC. Any import of `electronTrpc*` in the new FilePane directory is a bug. + +All tRPC calls go through the imperative client returned by `useWorkspaceClient().trpcClient`; all event-bus subscriptions go through `getEventBus(hostUrl, tokenFn)` from `@superset/workspace-client`. The store itself is module-level but must be initialized from inside a React context once (to capture the trpcClient and host URL resolver); after that it runs imperatively. + +`packages/workspace-fs/src/watch.ts` already coalesces rapid-fire events, pairs delete+create sequences into `rename` events, and filters atomic-write false positives via `@parcel/watcher`. By the time we see a `delete` event it's a real delete, not a transient artifact. No debounced probe needed. + +**Initialization pattern** (inside the v2 workspace route): + +```tsx +// Some provider mounted inside v2-workspace/$workspaceId/ that initializes the store once +export function FileDocumentStoreProvider({ children }: { children: ReactNode }) { + const { trpcClient } = useWorkspaceClient(); + const hostUrl = useWorkspaceHostUrl(workspaceId); + + useEffect(() => { + if (!hostUrl) return; + initializeFileDocumentStore({ + trpcClient, + hostUrl, + tokenGetter: () => getHostServiceWsToken(hostUrl), + }); + return () => teardownFileDocumentStore(); + }, [trpcClient, hostUrl]); + + return <>{children}; +} +``` + +Once initialized, the store has what it needs to make imperative calls and subscribe to the event bus without any further React plumbing. + +**Store-level event handling** (runs after init, one subscription per workspace host, not per-entry): + +```ts +// Pseudocode inside fileDocumentStore.ts +function subscribeToFsEvents() { + const bus = getEventBus(hostUrl, tokenGetter); + bus.watchFs(workspaceId); + + const remove = bus.on("fs:events", workspaceId, (_wid, payload) => { + for (const event of payload.events) { + dispatchFsEvent(event); + } + }); + + const release = bus.retain(); + + return () => { + remove(); + bus.unwatchFs(workspaceId); + release(); + }; +} + +function dispatchFsEvent(event: FsWatchEvent) { + for (const entry of entries.values()) { + const affects = + entry.absolutePath === event.absolutePath || + (event.kind === "rename" && entry.absolutePath === event.oldAbsolutePath); + if (!affects) continue; + + switch (event.kind) { + case "delete": + entry.orphaned = true; + notify(entry); + break; + + case "rename": + entry.absolutePath = event.absolutePath; + if (entry.dirty) { + entry.hasExternalChange = true; + } + // path updated; if not dirty, in-memory content still matches β€” no reload needed + notify(entry); + break; + + case "create": + case "update": + case "overflow": + if (entry.dirty) { + entry.hasExternalChange = true; + notify(entry); + } else { + void reloadFromDisk(entry); + } + break; + } + } +} + +async function reloadFromDisk(entry: DocumentEntry) { + // Imperative tRPC call via the injected client β€” NOT electronTrpc + const result = await trpcClient.filesystem.readFile.query({ + workspaceId: entry.workspaceId, + absolutePath: entry.absolutePath, + }); + // ... update entry.content + revision + notify +} +``` + +Orphan re-appearance: if a `create` event lands on an `orphaned` entry with a `dirty` buffer, clear `orphaned` but keep `dirty` (user still has unsaved edits over newly-written disk content; they can resolve via the conflict dialog on next save). + +### 3.4 Dispose rules + +- `releaseDocument` decrements refCount +- If `refCount === 0` AND `!dirty` AND `!orphaned` β†’ tear down entry +- If `refCount === 0` AND (`dirty` OR `orphaned`) β†’ entry remains alive until explicit `discard()` or `save()` clears the flags + +This mirrors VS Code's `TextFileEditorModelManager.canDispose()` which blocks disposal on dirty models. Prevents losing unsaved buffers when the last tab closes. + +--- + +## 4. View inventory + +### 4.1 Launch views + +| View id | Label | Matcher | Priority | `documentKind` | Notes | +|---|---|---|---|---|---| +| `image` | `Image` | `isImageFile(fp)` | `exclusive` | `bytes` | Suppresses alternatives | +| `binary-warning` | `Binary` | `meta.isBinary === true` | `exclusive` | `bytes` | "Open Anyway" β†’ `forceViewId: "code"` | +| `markdown-preview` | `Preview` | `isMarkdownFile(fp)` | `option` | `text` | Yields to code; appears in toggle | +| `code` | `Code` *(labelled `Markdown` on `.md` via override)* | `() => true` | `builtin` | `text` | Universal fallback | + +Label override note: the code view's static label is `"Code"`, but on markdown files the toggle should read `"Markdown"` (matching Cursor). Two options: +- **(a) Context-aware label**: `label: (filePath) => isMarkdownFile(filePath) ? "Markdown" : "Code"` β€” requires the label field to accept a function. +- **(b) Second registration**: register a `markdown-code` view with `match: isMarkdownFile`, `priority: "builtin"`, `label: "Markdown"` and pull `code`'s matcher to exclude markdown. Cleaner registry, more registrations. + +Decision: **(a)**. Label becomes `string | ((filePath: string) => string)`, resolved at render time. + +### 4.2 Priority choices (why each view uses what it does) + +- `code` is `builtin` β€” beats `option`, loses to `default`. Wins on `.ts/.py/.md/etc` but yields to CSV grid, JSON form, etc. +- `markdown-preview` is `option` β€” the only tier below `builtin`. This is the one file type where we want the universal fallback to beat the specialist. +- `image` is `exclusive` β€” no alternatives. Future: user hits `Reopen With…` and sets `forceViewId: "code"` to open as text. +- `binary-warning` is `exclusive` β€” forces the warning gate before any rendering. + +### 4.3 Future views + +| View id | Matcher | Priority | `documentKind` | +|---|---|---|---| +| `csv-grid` | `*.csv`, `*.tsv` | `default` | `text` | +| `json-form` | `package.json`, `tsconfig.json`, etc. | `default` | `text` | +| `env-form` | `.env*` | `default` | `text` | +| `notebook` | `*.ipynb` | `exclusive` | `custom` | +| `sqlite` | `*.sqlite`, `*.db` | `exclusive` | `custom` | +| `pdf` | `*.pdf` | `exclusive` | `bytes` | +| `hex` | β€” (via `Reopen With…`) | `option` | `custom` | + +Each future view = one new directory + one line in `allViews.ts`. No changes to `FilePane` or `resolveViews`. + +### 4.4 Resolution + +```ts +function resolveViews(filePath: string, meta: FileMeta): FileView[] { + const matches = ALL_VIEWS.filter((v) => v.match(filePath, meta)); + const exclusives = matches.filter((v) => v.priority === "exclusive"); + if (exclusives.length > 0) return exclusives; + return [...matches].sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]); +} + +function orderForToggle(views: FileView[]): FileView[] { + return [...views].reverse(); // default ends up on the right (Cursor RTL) +} + +function pickDefaultView(views: FileView[]): FileView { + return views[0]; // first after priority sort +} +``` + +--- + +## 5. Responsibility split + +### 5.1 `FilePane.tsx` owns + +- Document acquisition via `useSharedFileDocument` +- View resolution via `resolveViews` + active view selection (`data.viewId ?? pickDefaultView(views).id`) +- `forceViewId` bypass for binary-warning "Open Anyway" +- Mirroring `document.dirty` back to `data.hasChanges` for the tab indicator +- **Content gating** β€” renders `LoadingState` / `ErrorState` and does NOT mount the active view until `document.content.kind ∈ {text, bytes}` +- **Chrome rendering** β€” `FileViewToggle` (when `views.length > 1`), `ExternalChangeBar`, `OrphanedBanner`, `SaveErrorBanner`, `ConflictDialog` +- **Close-pane save guard** β€” wires `usePaneRegistry.tsx:154` TODO via non-hook `fileDocumentStore.get()` + `document.save()` + +### 5.2 `FileView.Renderer` owns + +- Rendering `document.content.value` (text or bytes β€” view knows its kind) +- Reporting edits via `onDirtyChange` + `document.setContent` +- Handling `Cmd+S` via `document.save()` +- Find UI (mounted inside the view; zero FilePane involvement β€” CodeView uses CodeMirror's native search panel, MarkdownPreviewView ports v1's `MarkdownSearch`, ImageView has no find) +- Undo history, cursor, selection, scroll position +- Focus management +- View-specific context menu entries (if any) + +### 5.3 `SharedFileDocument` owns + +- File I/O (read, write, exists-probe) +- ETag / revision tracking +- Dirty detection (`currentContent !== savedContent`) +- External-change detection (fs:events subscription) +- Orphan detection (delete probe with 100ms debounce) +- Save state machine (`dirty` β†’ `pendingSave` β†’ `saved` | `saveError` | `conflict`) +- Refcount + lifetime rules (dispose blocked on dirty/orphaned) +- Event fan-out to subscribers + +### 5.4 Find architecture note + +VS Code's three-layer find (shared `FindReplaceState`, shared `FindInput` DOM primitives, per-editor widget + search model) only pays off when you have multiple custom widgets with similar UX but different underlying engines. At launch we have two views with find: CodeView (CodeMirror's native search, fully baked) and MarkdownPreviewView (DOM-range search, ported from v1). Neither duplicates work β€” CodeMirror ships its own widget; the markdown search is small enough to own inline. No shared base needed at launch. Revisit when we ship a third view (CSV grid, notebook) whose find UI visibly resembles another view's. + +--- + +## 6. Flows + +### 6.1 Open + +1. `FilesTab` click β†’ `openFilePane(filePath, { openInNewTab })` in `page.tsx` +2. `FilePaneData = { filePath, mode: "editor", hasChanges: false, viewId: undefined }` +3. `FilePane.tsx` mounts +4. `useSharedFileDocument(workspaceId, filePath)` β†’ `acquireDocument` β†’ refcount 0β†’1 +5. Store triggers async `filesystem.readFile`; initial state is `phase=loading` +6. `FilePane.tsx` first render: `document.content.kind === "loading"` β†’ renders `LoadingState`, view does NOT mount +7. Read completes β†’ `content.kind` transitions to `text`/`bytes`/`not-found`/`too-large`/`is-directory`; binary probe runs, sets `isBinary` +8. `resolveViews(filePath, { size, isBinary })` β†’ matching view list +9. Active view renderer mounts with `document` + `filePath` props + +### 6.2 Save + +1. View calls `document.save()` (via CodeMirror keymap, TipTap keymap, etc.) +2. Document transitions: `dirty` β†’ `dirty + pendingSave` +3. `filesystem.writeFile` with `precondition: { ifMatch: revision }` +4. **Success**: revision updates; `dirty = false`; `pendingSave = false`; `savedContent = currentContent`; subscribers notified; tab dirty-dot clears +5. **Conflict (ETag mismatch)**: `pendingSave = false`; `conflict = { diskContent, diskRevision }`; `dirty` stays true; `SaveErrorBanner` does NOT show; `ConflictDialog` shows +6. **Other error**: `pendingSave = false`; `saveError = error`; `dirty` stays true; `SaveErrorBanner` shows; view remains editable + +### 6.3 Conflict resolution + +- `resolveConflict("reload")` β†’ `document.content = conflict.diskContent`; `currentContent = savedContent = diskContent`; `revision = diskRevision`; `dirty = false`; `conflict = null` +- `resolveConflict("overwrite")` β†’ `document.save({ force: true })` which skips the `ifMatch` precondition +- `resolveConflict("keep")` β†’ `conflict = null`, but `dirty` stays true (user keeps editing against stale revision; next save will conflict again unless merged) + +### 6.4 View swap + +1. User clicks inactive tab in `FileViewToggle` +2. `FileViewToggle` calls `onChangeView("preview")` +3. `FilePane.tsx`: `context.actions.updateData({ ...data, viewId: "preview" })` +4. Re-render: `activeView` recomputes; old Renderer unmounts; new Renderer mounts +5. `useSharedFileDocument` is on `FilePane`, NOT views β†’ document stays alive; refcount unaffected +6. `document.currentContent`, `dirty`, `conflict`, `orphaned` all preserved across the swap +7. Per-view state (undo history, scroll, cursor, find query) does NOT carry over β€” each view has its own + +### 6.5 External change (disk edited while editor is open) + +- `fs:events` change for a held path: + - `!dirty` β†’ store calls `reloadFromDisk(entry)` silently; content updates; subscribers notified + - `dirty` β†’ `hasExternalChange = true`; `ExternalChangeBar` shows with Reload / Review Diff buttons +- User clicks Reload β†’ `document.reload()` β†’ discards in-memory buffer, loads disk content, clears `hasExternalChange` and `dirty` + +### 6.6 External delete + +- `fs:events` delete for a held path β†’ `orphaned = true` immediately (watcher already filtered out atomic-write false positives) +- `OrphanedBanner` shows +- `dirty`: view stays mounted with unsaved buffer; user must `Save As` or `Discard` +- `!dirty`: view stays mounted with last-known content; user sees banner +- File re-appears on disk later (`create` event on orphaned entry): + - `!dirty`: clear `orphaned`, reload content silently + - `dirty`: clear `orphaned`, set `hasExternalChange = true`; user resolves via conflict dialog on next save + +### 6.7 Close with dirty + +1. User clicks close on a tab with `data.hasChanges === true` +2. `usePaneRegistry.onBeforeClose` reads `data.hasChanges`, returns an `alert(...)` Promise +3. Dialog: Save / Don't Save / Cancel +4. **Save** β†’ calls `document.save()`; on success, resolves `true` (close proceeds); on conflict/error, resolves `false` (close blocked, banner shows) +5. **Don't Save** β†’ calls `document.discard()` which forces refcount to allow teardown; resolves `true` +6. **Cancel** β†’ resolves `false` + +Blocker: `FilePane` holds the document; `onBeforeClose` runs on pane data only. Either expose a `documentHandle` via pane context, or have the store expose a non-hook `getDocument(workspaceId, filePath)` for non-React callers. + +Decision: **non-hook store access** (`fileDocumentStore.get(workspaceId, filePath)`), used by `onBeforeClose`. Keeps the registry decoupled from React rendering. + +--- + +## 7. FilePane component (concrete) + +One component. Acquires the document, resolves views, gates on content state, renders chrome, mounts the active view. + +```tsx +// FilePane.tsx + +export function FilePane({ context, workspaceId }: FilePaneProps) { + const data = context.pane.data as FilePaneData; + const { filePath } = data; + + const document = useSharedFileDocument({ workspaceId, absolutePath: filePath }); + + // View resolution + const meta: FileMeta = { + size: document.byteSize ?? undefined, + isBinary: document.isBinary ?? undefined, + }; + const views = data.forceViewId + ? ALL_VIEWS.filter((v) => v.id === data.forceViewId) + : resolveViews(filePath, meta); + const activeView = views.find((v) => v.id === data.viewId) ?? pickDefaultView(views); + const ViewRenderer = activeView.Renderer; + + // Handlers + const handleChangeView = useCallback( + (viewId: string) => { + context.actions.updateData({ ...data, viewId } as PaneViewerData); + }, + [context.actions, data], + ); + const handleDirtyChange = useCallback( + (dirty: boolean) => { + if (dirty !== data.hasChanges) { + context.actions.updateData({ ...data, hasChanges: dirty } as PaneViewerData); + } + }, + [context.actions, data], + ); + + // Content gating β€” view not mounted until there's renderable content + if (document.content.kind === "loading") { + return ; + } + if (document.content.kind === "not-found" && !document.orphaned) { + return ; + } + if (document.content.kind === "too-large") { + return ; + } + if (document.content.kind === "is-directory") { + return ; + } + + // Chrome + active view + const showToggle = views.length > 1; + return ( +
+ {showToggle && ( +
+ +
+ )} + {document.hasExternalChange && } + {document.orphaned && } + {document.saveError && } +
+ +
+ {document.conflict && } +
+ ); +} +``` + +--- + +## 8. Build stages + +Organized as thin-vertical-slice + grow-outward. Each PR lands something runnable and testable end-to-end β€” you can `bun dev`, open a file, see what changed. No refactor-only PRs. No half-wired intermediate states. + +Ships behind a feature flag (`fileEditorV2Enabled` or similar) so the old v2 `CodeRenderer`/`MarkdownRenderer`/`ImageRenderer` path keeps working throughout the build. Flag flips in the final PR. + +### PR 1 β€” Thin e2e slice (the hard one) + +Build just enough to render one view end-to-end. Missing features are acknowledged and deferred; the point is to prove the stack works. + +**Scope:** +- `fileDocumentStore` β€” minimum viable state machine: `phase`, `content`, `dirty`, refcount, subscribe, `acquireDocument`/`releaseDocument`/`get`, `save` via tRPC with ETag precondition. **Deferred**: `pendingSave`, `saveError`, `conflict`, `orphaned`, `hasExternalChange`, fs:events subscription. +- `useSharedFileDocument` hook +- Registry: `types.ts`, `resolveViews.ts`, `allViews.ts` containing only `codeView` +- Duplicated `CodeEditor.tsx` + deps at `registry/views/CodeView/components/CodeEditor/` +- `CodeView` component +- `FilePane.tsx` rewrite: acquire doc, resolve views, mount active view, minimal `LoadingState` gate +- Feature flag wiring: new path when flag is on, old path when off + +**Acceptance:** flag on β†’ open a `.ts` file, edit, Cmd+S saves, tab dirty dot appears and clears. Split the tab, open the same file in the other pane, edit in one, see the content sync in real time (refcount sharing works). Flag off β†’ old behavior unchanged. This proves: store, registry, FilePane dispatch, shared buffer, save flow. + +**Visible gaps (known, acceptable for this PR):** no markdown preview, no image view, no binary warning, no conflict dialog, no orphan handling, no external-change banner, no save-error banner, no toggle (only one view registered). + +### PR 2 β€” Second view unlocks the toggle + +Adds the registry's scaling proof: multiple views, the segmented toggle, shared document across view swaps. + +**Scope:** +- `MarkdownPreviewView` (TipTap) + ported `MarkdownSearch` colocated inside the view dir +- `FileViewToggle` component +- `ImageView` (small, no toggle, but fits naturally here since it doesn't add complexity) + +**Acceptance:** flag on β†’ open a `.md` file, starts in code view labelled "Markdown", toggle appears on the right with "Preview Β· Markdown" RTL. Click Preview, content stays synced across the swap, edits in one view are visible in the other after toggling back. Open a `.png`, image renders, no toggle shown. This proves: multi-view registry, RTL toggle ordering, view swap preserves document state. + +### PR 3 β€” State machine completion + chrome + +Fills in the state machine fields that PR 1 deferred and builds all the banners/dialogs. + +**Scope:** +- Expand `fileDocumentStore` with `pendingSave`, `saveError`, `conflict`, `hasExternalChange` +- `ExternalChangeBar` component +- `SaveErrorBanner` component +- `ConflictDialog` component (ported from v1's `FileSaveConflictDialog`) +- `ErrorState` component (not-found, too-large, is-directory) +- Close-pane save guard fix via non-hook `fileDocumentStore.get()` in `usePaneRegistry.tsx:154` + +**Acceptance:** flag on β†’ edit a file externally while v2 is showing it β†’ `ExternalChangeBar` appears. Close a dirty tab β†’ alert prompts save/discard/cancel, all three work. Simulate ETag mismatch (easiest: two tabs on the same file in two separate desktop processes, edit+save in both) β†’ `ConflictDialog` shows. Open a nonexistent path β†’ `ErrorState reason="not-found"`. + +### PR 4 β€” fs:events + orphan + binary + +Wires up the watcher and adds the last launch view. + +**Scope:** +- `orphaned` flag on the store +- fs:events subscription in `fileDocumentStore` (the `switch` over `create | update | delete | rename | overflow` from Β§3.3) +- `OrphanedBanner` component +- Dispose rules: block teardown when `dirty` or `orphaned` +- Rename path tracking +- `BinaryWarningView` + `meta.isBinary` threading + `forceViewId` bypass + +**Acceptance:** `rm` an open file from a terminal β†’ `OrphanedBanner` appears immediately. Edit the file externally with `vim :w` β†’ `rename` event fires, no phantom orphan banner, `ExternalChangeBar` shows instead. Close a dirty tab β†’ dialog; "Don't Save" actually drops the buffer; "Save" persists and closes cleanly. Open a `.so` β†’ `BinaryWarningView` shown; click "Open Anyway" β†’ opens as code. + +### PR 5 β€” Cleanup and flip + +Mechanical. Deletes the old path. + +**Scope:** +- Flip the feature flag default to on +- Delete `CodeRenderer.tsx`, `MarkdownRenderer.tsx`, `ImageRenderer.tsx` +- Delete v2's current `useFileDocument` host-service hook (if no non-FilePane consumers remain β€” verify first) +- Remove the feature flag entirely +- Clean up any dead imports / unused exports + +**Acceptance:** no references to the old renderer components remain; typecheck passes; full regression suite (Β§9.2) runs clean. + +### Post-launch follow-up PRs (each independently small) + +- Context menu (audit Β§8): copy path, copy path:line, reveal in sidebar, open in external editor +- Hotkey wiring: Cmd+Shift+C (copy path:line), Cmd+Shift+R (reopen tab), prev/next tab, prev/next pane +- Link detection / Cmd+click on paths (audit Β§14, Β§15 tier 1) +- CSV grid view +- JSON form view for `package.json` / `tsconfig.json` +- `Reopen With…` menu for explicit handler override +- Per-glob user override setting (`fileViewOverrides`) +- Go-to-line command (Cmd+G) +- Sticky scroll extension +- Breadcrumb path in pane body + +Each of these touches one view or one component in isolation and doesn't require coordinating across the stack. + +### Why this shape + +- **PR 1 is the big one** (~600–1000 lines, half of that is the duplicated CodeEditor). Reviewable. Ships a working surface. +- **PRs 2–4 each add testable user-visible behavior.** No refactor-only PRs. Every PR has an acceptance check you can run manually in `bun dev`. +- **Flag isolates risk.** Old path coexists until PR 5. If PR 3 breaks something, PR 4 can still merge with the flag off. +- **Intermediate states are honest.** After PR 2 the flag path is already a usable editor for the three main file types (code, markdown, image). After PR 3 it matches v1 for conflict + external-change handling. After PR 4 it matches or exceeds v1 for everything except context menu + hotkeys. +- **Follow-up PRs are actually small.** The coupled refactor work is done in PRs 1–5; everything after is single-feature additions. + +--- + +## 9. Verification + +### 9.1 Per-PR + +- `bun typecheck` β€” must pass +- `bun run lint` β€” must pass +- `bun dev` β†’ open v2 workspace β†’ execute the PR's acceptance check +- Feature flag toggled off β†’ regression check that old v2 still works unchanged + +### 9.2 Regression suite (run after every PR) + +Tests that map to which PR introduced them β€” not every check applies to every PR. + +| Check | Introduced by | +|---|---| +| Open `.ts` file β†’ code view, no toggle, Cmd+S saves | PR 1 | +| Split pane, open same file in both β†’ edits sync, dirty dot on both | PR 1 | +| Open `.md` file β†’ Markdown view default, toggle shows "Preview Β· Markdown" | PR 2 | +| Click Preview β†’ TipTap renders same content, swap preserves dirty state | PR 2 | +| Open `.png` β†’ image view, no toggle | PR 2 | +| Edit file externally β†’ `ExternalChangeBar` appears | PR 3 | +| Edit file, close tab β†’ save/discard/cancel prompt, all three work | PR 3 | +| ETag mismatch on save β†’ `ConflictDialog` shows, all three resolutions work | PR 3 | +| Open nonexistent path β†’ `ErrorState reason="not-found"` | PR 3 | +| `rm` open file from terminal β†’ `OrphanedBanner` appears | PR 4 | +| `vim :w` β†’ `rename` event fires, no phantom orphan banner | PR 4 | +| Open `.so` β†’ binary warning + "Open Anyway" β†’ code view | PR 4 | +| Feature flag off β†’ old v2 path unchanged | PRs 1–4 | + +### 9.3 Manual edge cases + +- Empty file +- Very large file at the 2MB boundary +- File with CRLF vs LF line endings +- Symbolic link +- File whose name has unicode characters +- File in a deeply nested path +- File on a case-insensitive filesystem where case of filename changes externally + +--- + +## 10. Open decisions + +| # | Question | Default | Needs decision by | +|---|---|---|---| +| 1 | Label override: function-per-view or second registration? | function | PR 2 | +| 2 | Binary detection: sync-in-readFile or async-after-first-render? | sync-in-readFile | PR 4 | +| 3 | `viewId` persistence migration for existing v2 pane data? | additive, default `undefined`, no migration | PR 1 | +| 4 | Per-glob user override setting (`fileViewOverrides`)? | deferred post-launch | β€” | +| 5 | `Reopen With…` menu? | deferred post-launch | β€” | +| 6 | Per-view undo history vs unified? | per-view (matches VS Code) | PR 2 | +| 7 | Orphan auto-clear on file reappearance? | Resolved β€” see Flow 6.6: clear `orphaned`, silently reload if clean, flag `hasExternalChange` if dirty | β€” | From bd4a5a89cd771ce46bda21ff48d98bcd1b63c910 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 12:27:08 -0700 Subject: [PATCH 02/16] =?UTF-8?q?feat(desktop):=20v2=20file=20editor=20fou?= =?UTF-8?q?ndation=20=E2=80=94=20shared=20document=20store=20+=20registry?= =?UTF-8?q?=20+=20CodeView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/FilePane/FilePane.tsx | 114 +++----- .../components/ErrorState/ErrorState.tsx | 24 ++ .../FilePane/components/ErrorState/index.ts | 1 + .../components/LoadingState/LoadingState.tsx | 7 + .../FilePane/components/LoadingState/index.ts | 1 + .../components/FilePane/registry/allViews.ts | 6 + .../components/FilePane/registry/index.ts | 10 + .../FilePane/registry/resolveViews.ts | 17 ++ .../components/FilePane/registry/types.ts | 35 +++ .../registry/views/CodeView/CodeView.tsx | 20 ++ .../components/CodeEditor/CodeEditor.tsx | 261 ++++++++++++++++++ .../CodeEditor/CodeEditorAdapter.ts | 141 ++++++++++ .../components/CodeEditor/constants.ts | 3 + .../CodeEditor/createCodeMirrorTheme.ts | 92 ++++++ .../CodeView/components/CodeEditor/index.ts | 5 + .../CodeEditor/loadLanguageSupport.ts | 127 +++++++++ .../components/CodeEditor/streamLanguages.ts | 232 ++++++++++++++++ .../CodeEditor/syntax-highlighting.ts | 70 +++++ .../FilePane/registry/views/CodeView/index.ts | 11 + .../v2-workspace/$workspaceId/page.tsx | 5 +- .../FileDocumentStoreProvider.tsx | 25 ++ .../fileDocumentStore/fileDocumentStore.ts | 212 ++++++++++++++ .../state/fileDocumentStore/index.ts | 9 + .../state/fileDocumentStore/types.ts | 29 ++ .../useSharedFileDocument.ts | 27 ++ .../WorkspaceClientProvider.tsx | 2 + 26 files changed, 1407 insertions(+), 79 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/LoadingState.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/constants.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/streamLanguages.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index 88e84955695..076d4eb3b4d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -1,11 +1,10 @@ import type { RendererContext } from "@superset/panes"; -import { useCallback } from "react"; -import { useFileDocument } from "renderer/hooks/host-service/useFileDocument"; -import { isImageFile, isMarkdownFile } from "shared/file-types"; +import { useEffect } from "react"; +import { useSharedFileDocument } from "../../../../state/fileDocumentStore"; import type { FilePaneData, PaneViewerData } from "../../../../types"; -import { CodeRenderer } from "./renderers/CodeRenderer"; -import { ImageRenderer } from "./renderers/ImageRenderer"; -import { MarkdownRenderer } from "./renderers/MarkdownRenderer"; +import { ErrorState } from "./components/ErrorState"; +import { LoadingState } from "./components/LoadingState"; +import { pickDefaultView, resolveViews } from "./registry"; interface FilePaneProps { context: RendererContext; @@ -16,91 +15,52 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { const data = context.pane.data as FilePaneData; const { filePath } = data; - const document = useFileDocument({ + const document = useSharedFileDocument({ workspaceId, absolutePath: filePath, - mode: isImageFile(filePath) ? "bytes" : "auto", - maxBytes: isImageFile(filePath) ? 10 * 1024 * 1024 : 2 * 1024 * 1024, - hasLocalChanges: data.hasChanges, - autoReloadWhenClean: true, }); - const handleDirtyChange = useCallback( - (dirty: boolean) => { - if (dirty !== data.hasChanges) { - context.actions.updateData({ - ...data, - hasChanges: dirty, - } as PaneViewerData); - } - }, - [context.actions, data], - ); - - const handleSave = useCallback( - async (content: string) => { - const result = await document.save({ content }); - if (result.status === "saved") { - handleDirtyChange(false); - } - return result; - }, - [document, handleDirtyChange], - ); + // Mirror document dirty state back into the pane data so the tab indicator stays in sync. + useEffect(() => { + if (document.dirty !== data.hasChanges) { + context.actions.updateData({ + ...data, + hasChanges: document.dirty, + } as PaneViewerData); + } + }, [document.dirty, data, context.actions]); - if (document.state.kind === "loading") { - return null; + // Content gating β€” nothing mounts until the document has renderable content. + if (document.content.kind === "loading") { + return ; } - - if (document.state.kind === "not-found") { - return ( -
- File not found -
- ); + if (document.content.kind === "not-found") { + return ; } - - if (document.state.kind === "too-large") { - return ( -
- File is too large to display -
- ); + if (document.content.kind === "too-large") { + return ; } - - if (document.state.kind === "binary" || document.state.kind === "bytes") { - if (isImageFile(filePath) && document.state.kind === "bytes") { - return ( - - ); - } - return ( -
- Binary file β€” cannot display -
- ); + if (document.content.kind === "is-directory") { + return ; + } + if (document.content.kind === "bytes") { + // PR 1 does not ship a bytes-capable view. Image/binary views arrive in PR 2. + return ; } - if (isMarkdownFile(filePath)) { - return ( - - ); + const views = resolveViews(filePath, {}); + const activeView = pickDefaultView(views); + if (!activeView) { + return ; } + const ViewRenderer = activeView.Renderer; + return ( - ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx new file mode 100644 index 00000000000..347727baa48 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx @@ -0,0 +1,24 @@ +export type ErrorReason = + | "not-found" + | "too-large" + | "is-directory" + | "binary-unsupported"; + +interface ErrorStateProps { + reason: ErrorReason; +} + +const MESSAGES: Record = { + "not-found": "File not found", + "too-large": "File is too large to preview", + "is-directory": "This path is a directory", + "binary-unsupported": "Binary file β€” cannot display", +}; + +export function ErrorState({ reason }: ErrorStateProps) { + return ( +
+ {MESSAGES[reason]} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/index.ts new file mode 100644 index 00000000000..297e43f6c2f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/index.ts @@ -0,0 +1 @@ +export { type ErrorReason, ErrorState } from "./ErrorState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/LoadingState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/LoadingState.tsx new file mode 100644 index 00000000000..47472cc169d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/LoadingState.tsx @@ -0,0 +1,7 @@ +export function LoadingState() { + return ( +
+ Loading… +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/index.ts new file mode 100644 index 00000000000..3ca614e829f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/index.ts @@ -0,0 +1 @@ +export { LoadingState } from "./LoadingState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts new file mode 100644 index 00000000000..f97d1e4bc55 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts @@ -0,0 +1,6 @@ +import type { FileView } from "./types"; +import { codeView } from "./views/CodeView"; + +// Order is preserved as a stable tiebreaker for equal-priority views. +// PR 1 ships only the code view; markdown/image/binary views arrive in later PRs. +export const ALL_VIEWS: FileView[] = [codeView]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts new file mode 100644 index 00000000000..9a77c9f02f5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts @@ -0,0 +1,10 @@ +export { ALL_VIEWS } from "./allViews"; +export { pickDefaultView, resolveViews } from "./resolveViews"; +export { + type DocumentKind, + type FileMeta, + type FileView, + PRIORITY_RANK, + type Priority, + type ViewProps, +} from "./types"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts new file mode 100644 index 00000000000..f9f25789397 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts @@ -0,0 +1,17 @@ +import { ALL_VIEWS } from "./allViews"; +import { type FileMeta, type FileView, PRIORITY_RANK } from "./types"; + +export function resolveViews(filePath: string, meta: FileMeta): FileView[] { + const matches = ALL_VIEWS.filter((view) => view.match(filePath, meta)); + const exclusives = matches.filter((v) => v.priority === "exclusive"); + if (exclusives.length > 0) { + return exclusives; + } + return [...matches].sort( + (a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority], + ); +} + +export function pickDefaultView(views: FileView[]): FileView | null { + return views[0] ?? null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts new file mode 100644 index 00000000000..62bb75c5460 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts @@ -0,0 +1,35 @@ +import type { ComponentType } from "react"; +import type { SharedFileDocument } from "../../../../../state/fileDocumentStore"; + +export type FileMeta = { + size?: number; + isBinary?: boolean; +}; + +export type DocumentKind = "text" | "bytes" | "custom"; + +// Priorities mirror VS Code's RegisteredEditorPriority +// (editorResolverService.ts). Ranking: exclusive > default > builtin > option. +export type Priority = "builtin" | "option" | "default" | "exclusive"; + +export const PRIORITY_RANK: Record = { + exclusive: 5, + default: 4, + builtin: 3, + option: 1, +}; + +export interface FileView { + id: string; + label: string; + match: (filePath: string, meta: FileMeta) => boolean; + priority: Priority; + documentKind: DocumentKind; + Renderer: ComponentType; +} + +export interface ViewProps { + document: SharedFileDocument; + filePath: string; + workspaceId: string; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx new file mode 100644 index 00000000000..d4f5305ebcc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx @@ -0,0 +1,20 @@ +import { detectLanguage } from "shared/detect-language"; +import type { ViewProps } from "../../types"; +import { CodeEditor } from "./components/CodeEditor"; + +export function CodeView({ document, filePath }: ViewProps) { + if (document.content.kind !== "text") { + return null; + } + + return ( + document.setContent(next)} + onSave={() => void document.save()} + fillHeight + /> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx new file mode 100644 index 00000000000..d1a7d06713b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx @@ -0,0 +1,261 @@ +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from "@codemirror/commands"; +import { bracketMatching, indentOnInput } from "@codemirror/language"; +import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; +import { Compartment, EditorState } from "@codemirror/state"; +import { + drawSelection, + dropCursor, + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + highlightSpecialChars, + keymap, + lineNumbers, +} from "@codemirror/view"; +import { cn } from "@superset/ui/utils"; +import { useQuery } from "@tanstack/react-query"; +import { type MutableRefObject, useEffect, useRef } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useResolvedTheme } from "renderer/stores/theme"; +import { + type CodeEditorAdapter, + createCodeMirrorAdapter, +} from "./CodeEditorAdapter"; +import { createCodeMirrorTheme } from "./createCodeMirrorTheme"; +import { loadLanguageSupport } from "./loadLanguageSupport"; +import { getCodeSyntaxHighlighting } from "./syntax-highlighting"; + +interface CodeEditorProps { + value: string; + language: string; + readOnly?: boolean; + fillHeight?: boolean; + className?: string; + editorRef?: MutableRefObject; + onChange?: (value: string) => void; + onSave?: () => void; +} + +export function CodeEditor({ + value, + language, + readOnly = false, + fillHeight = true, + className, + editorRef, + onChange, + onSave, +}: CodeEditorProps) { + const containerRef = useRef(null); + const viewRef = useRef(null); + const languageCompartment = useRef(new Compartment()).current; + const themeCompartment = useRef(new Compartment()).current; + const editableCompartment = useRef(new Compartment()).current; + const onChangeRef = useRef(onChange); + const onSaveRef = useRef(onSave); + // Guards against re-entrant onChange calls triggered by the value-sync effect's own dispatch. + const isExternalUpdateRef = useRef(false); + const { data: fontSettings } = useQuery({ + queryKey: ["electron", "settings", "getFontSettings"], + queryFn: () => electronTrpcClient.settings.getFontSettings.query(), + staleTime: 30_000, + }); + const editorFontFamily = fontSettings?.editorFontFamily ?? undefined; + const editorFontSize = fontSettings?.editorFontSize ?? undefined; + const activeTheme = useResolvedTheme(); + + onChangeRef.current = onChange; + onSaveRef.current = onSave; + + // biome-ignore lint/correctness/useExhaustiveDependencies: Editor instance is created once and reconfigured via dedicated effects below + useEffect(() => { + if (!containerRef.current) return; + + const updateListener = EditorView.updateListener.of((update) => { + if (!update.docChanged) return; + if (isExternalUpdateRef.current) return; + onChangeRef.current?.(update.state.doc.toString()); + }); + + const saveKeymap = keymap.of([ + { + key: "Mod-s", + run: () => { + onSaveRef.current?.(); + return true; + }, + }, + ]); + + const state = EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + highlightActiveLine(), + highlightSelectionMatches(), + EditorView.lineWrapping, + editableCompartment.of([ + EditorState.readOnly.of(readOnly), + EditorView.editable.of(!readOnly), + ]), + EditorView.contentAttributes.of({ + spellcheck: "false", + }), + keymap.of([ + indentWithTab, + ...defaultKeymap, + ...historyKeymap, + ...searchKeymap, + ]), + saveKeymap, + themeCompartment.of([ + getCodeSyntaxHighlighting(activeTheme), + createCodeMirrorTheme( + activeTheme, + { + fontFamily: editorFontFamily, + fontSize: editorFontSize, + }, + fillHeight, + ), + ]), + languageCompartment.of([]), + updateListener, + ], + }); + + const view = new EditorView({ + state, + parent: containerRef.current, + }); + const adapter = createCodeMirrorAdapter(view); + + viewRef.current = view; + if (editorRef) { + editorRef.current = adapter; + } + + return () => { + if (editorRef?.current === adapter) { + editorRef.current = null; + } + adapter.dispose(); + viewRef.current = null; + }; + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + const currentValue = view.state.doc.toString(); + if (currentValue === value) return; + + // Guarantee flag reset regardless of whether dispatch throws (e.g. view destroyed between null-check and dispatch). + isExternalUpdateRef.current = true; + try { + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: value, + }, + }); + } finally { + isExternalUpdateRef.current = false; + } + }, [value]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + view.dispatch({ + effects: themeCompartment.reconfigure([ + getCodeSyntaxHighlighting(activeTheme), + createCodeMirrorTheme( + activeTheme, + { + fontFamily: editorFontFamily, + fontSize: editorFontSize, + }, + fillHeight, + ), + ]), + }); + }, [ + activeTheme, + editorFontFamily, + editorFontSize, + fillHeight, + themeCompartment, + ]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + view.dispatch({ + effects: editableCompartment.reconfigure([ + EditorState.readOnly.of(readOnly), + EditorView.editable.of(!readOnly), + ]), + }); + }, [editableCompartment, readOnly]); + + useEffect(() => { + let cancelled = false; + + void loadLanguageSupport(language) + .then((extension) => { + if (cancelled) return; + const view = viewRef.current; + if (!view) return; + + view.dispatch({ + effects: languageCompartment.reconfigure(extension ?? []), + }); + }) + .catch((error) => { + if (cancelled) return; + const view = viewRef.current; + if (!view) return; + + console.error("[CodeEditor] Failed to load language support:", { + error, + language, + }); + view.dispatch({ + effects: languageCompartment.reconfigure([]), + }); + }); + + return () => { + cancelled = true; + }; + }, [language, languageCompartment]); + + return ( +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter.ts new file mode 100644 index 00000000000..86467104412 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter.ts @@ -0,0 +1,141 @@ +import { selectAll } from "@codemirror/commands"; +import { openSearchPanel } from "@codemirror/search"; +import { EditorSelection } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; + +export interface EditorSelectionLines { + startLine: number; + endLine: number; +} + +export interface CodeEditorAdapter { + focus(): void; + getValue(): string; + setValue(value: string): void; + revealPosition(line: number, column?: number): void; + getSelectionLines(): EditorSelectionLines | null; + selectAll(): void; + cut(): void; + copy(): void; + paste(): void; + openFind(): void; + dispose(): void; +} + +export function createCodeMirrorAdapter(view: EditorView): CodeEditorAdapter { + let disposed = false; + + return { + focus() { + view.focus(); + }, + getValue() { + return view.state.doc.toString(); + }, + setValue(value) { + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: value, + }, + }); + }, + revealPosition(line, column = 1) { + const safeLine = Math.max(1, Math.min(line, view.state.doc.lines)); + const lineInfo = view.state.doc.line(safeLine); + const offset = Math.min(column - 1, lineInfo.length); + const anchor = lineInfo.from + Math.max(0, offset); + + view.dispatch({ + selection: EditorSelection.cursor(anchor), + scrollIntoView: true, + }); + view.focus(); + }, + getSelectionLines() { + const selection = view.state.selection.main; + const startLine = view.state.doc.lineAt(selection.from).number; + const endLine = view.state.doc.lineAt(selection.to).number; + return { startLine, endLine }; + }, + selectAll() { + selectAll(view); + }, + cut() { + if (view.state.readOnly) return; + const clipboard = navigator.clipboard; + if (!clipboard) return; + + const selection = view.state.selection.main; + if (selection.empty) return; + + const text = view.state.sliceDoc(selection.from, selection.to); + void clipboard + .writeText(text) + .then(() => { + const currentSelection = view.state.selection.main; + if ( + currentSelection.from !== selection.from || + currentSelection.to !== selection.to + ) { + return; + } + + if (view.state.sliceDoc(selection.from, selection.to) !== text) { + return; + } + + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert: "" }, + }); + }) + .catch((error) => { + console.error("[CodeEditor] Failed to cut selection:", error); + }); + }, + copy() { + const clipboard = navigator.clipboard; + if (!clipboard) return; + + const selection = view.state.selection.main; + if (selection.empty) return; + + void clipboard + .writeText(view.state.sliceDoc(selection.from, selection.to)) + .catch((error) => { + console.error("[CodeEditor] Failed to copy selection:", error); + }); + }, + paste() { + if (view.state.readOnly) return; + const clipboard = navigator.clipboard; + if (!clipboard) return; + + void clipboard + .readText() + .then((text) => { + const selection = view.state.selection.main; + view.dispatch({ + changes: { + from: selection.from, + to: selection.to, + insert: text, + }, + selection: EditorSelection.cursor(selection.from + text.length), + }); + }) + .catch((error) => { + console.error("[CodeEditor] Failed to paste from clipboard:", error); + }); + }, + openFind() { + openSearchPanel(view); + }, + dispose() { + if (disposed) return; + disposed = true; + view.destroy(); + }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/constants.ts new file mode 100644 index 00000000000..63c8e6774d0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_CODE_EDITOR_FONT_FAMILY = + "ui-monospace, Menlo, Consolas, Liberation Mono, monospace"; +export const DEFAULT_CODE_EDITOR_FONT_SIZE = 13; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts new file mode 100644 index 00000000000..4d9b64a85c4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts @@ -0,0 +1,92 @@ +import { EditorView } from "@codemirror/view"; +import { getEditorTheme, type Theme } from "shared/themes"; +import { + DEFAULT_CODE_EDITOR_FONT_FAMILY, + DEFAULT_CODE_EDITOR_FONT_SIZE, +} from "./constants"; + +interface CodeEditorFontSettings { + fontFamily?: string; + fontSize?: number; +} + +export function createCodeMirrorTheme( + theme: Theme, + fontSettings: CodeEditorFontSettings, + fillHeight: boolean, +) { + const fontSize = fontSettings.fontSize ?? DEFAULT_CODE_EDITOR_FONT_SIZE; + const lineHeight = Math.round(fontSize * 1.5); + const editorTheme = getEditorTheme(theme); + + return EditorView.theme( + { + "&": { + height: fillHeight ? "100%" : "auto", + backgroundColor: editorTheme.colors.background, + color: editorTheme.colors.foreground, + fontFamily: fontSettings.fontFamily ?? DEFAULT_CODE_EDITOR_FONT_FAMILY, + fontSize: `${fontSize}px`, + }, + ".cm-scroller": { + fontFamily: "inherit", + lineHeight: `${lineHeight}px`, + overflow: fillHeight ? "auto" : "visible", + }, + ".cm-content": { + padding: "8px 0", + caretColor: editorTheme.colors.cursor, + }, + ".cm-line": { + padding: "0 12px", + }, + ".cm-gutters": { + backgroundColor: editorTheme.colors.gutterBackground, + color: editorTheme.colors.gutterForeground, + borderRight: `1px solid ${editorTheme.colors.border}`, + }, + ".cm-activeLine": { + backgroundColor: editorTheme.colors.activeLine, + }, + ".cm-activeLineGutter": { + backgroundColor: editorTheme.colors.activeLine, + }, + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { + backgroundColor: editorTheme.colors.selection, + }, + ".cm-selectionMatch": { + backgroundColor: editorTheme.colors.search, + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: editorTheme.colors.cursor, + }, + ".cm-searchMatch": { + backgroundColor: editorTheme.colors.search, + outline: "none", + }, + ".cm-searchMatch.cm-searchMatch-selected": { + backgroundColor: editorTheme.colors.searchActive, + }, + ".cm-panels": { + backgroundColor: editorTheme.colors.panel, + color: editorTheme.colors.foreground, + borderBottom: `1px solid ${editorTheme.colors.panelBorder}`, + }, + ".cm-panels .cm-textfield": { + backgroundColor: editorTheme.colors.panelInputBackground, + color: editorTheme.colors.panelInputForeground, + border: `1px solid ${editorTheme.colors.panelInputBorder}`, + }, + ".cm-button": { + backgroundImage: "none", + backgroundColor: editorTheme.colors.panelButtonBackground, + color: editorTheme.colors.panelButtonForeground, + border: `1px solid ${editorTheme.colors.panelButtonBorder}`, + }, + }, + { + dark: theme.type === "dark", + }, + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/index.ts new file mode 100644 index 00000000000..1143beaed43 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/index.ts @@ -0,0 +1,5 @@ +export { CodeEditor } from "./CodeEditor"; +export type { + CodeEditorAdapter, + EditorSelectionLines, +} from "./CodeEditorAdapter"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport.ts new file mode 100644 index 00000000000..876ca792f05 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport.ts @@ -0,0 +1,127 @@ +import { StreamLanguage, type StreamParser } from "@codemirror/language"; +import type { Extension } from "@codemirror/state"; +import { + graphqlStreamLanguage, + makefileStreamLanguage, +} from "./streamLanguages"; + +async function loadLegacyLanguage( + loader: () => Promise>, + key: string, +): Promise { + const languageModule = await loader(); + return StreamLanguage.define(languageModule[key] as StreamParser); +} + +export async function loadLanguageSupport( + language: string, +): Promise { + switch (language) { + case "typescript": + case "javascript": { + const { javascript } = await import("@codemirror/lang-javascript"); + return javascript({ + typescript: language === "typescript", + jsx: true, + }); + } + case "json": { + const { json } = await import("@codemirror/lang-json"); + return json(); + } + case "html": { + const { html } = await import("@codemirror/lang-html"); + return html(); + } + case "css": + case "scss": + case "less": { + const { css } = await import("@codemirror/lang-css"); + return css(); + } + case "markdown": { + const { markdown } = await import("@codemirror/lang-markdown"); + return markdown(); + } + case "graphql": + return StreamLanguage.define(graphqlStreamLanguage); + case "plaintext": + return null; + case "yaml": { + const { yaml } = await import("@codemirror/lang-yaml"); + return yaml(); + } + case "xml": { + const { xml } = await import("@codemirror/lang-xml"); + return xml(); + } + case "python": { + const { python } = await import("@codemirror/lang-python"); + return python(); + } + case "rust": { + const { rust } = await import("@codemirror/lang-rust"); + return rust(); + } + case "sql": { + const { sql } = await import("@codemirror/lang-sql"); + return sql(); + } + case "php": { + const { php } = await import("@codemirror/lang-php"); + return php(); + } + case "java": { + const { java } = await import("@codemirror/lang-java"); + return java(); + } + case "c": + case "cpp": { + const { cpp } = await import("@codemirror/lang-cpp"); + return cpp(); + } + case "go": { + const { go } = await import("@codemirror/lang-go"); + return go(); + } + case "shell": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/shell"), + "shell", + ); + case "dockerfile": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/dockerfile"), + "dockerFile", + ); + case "makefile": + return StreamLanguage.define(makefileStreamLanguage); + case "toml": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/toml"), + "toml", + ); + case "ruby": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/ruby"), + "ruby", + ); + case "swift": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/swift"), + "swift", + ); + case "csharp": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/clike"), + "csharp", + ); + case "kotlin": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/clike"), + "kotlin", + ); + default: + return null; + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/streamLanguages.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/streamLanguages.ts new file mode 100644 index 00000000000..7096bb018c7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/streamLanguages.ts @@ -0,0 +1,232 @@ +import type { StreamParser } from "@codemirror/language"; + +const GRAPHQL_KEYWORDS = new Set([ + "directive", + "enum", + "extend", + "fragment", + "implements", + "input", + "interface", + "mutation", + "on", + "query", + "repeatable", + "scalar", + "schema", + "subscription", + "type", + "union", +]); + +const GRAPHQL_ATOMS = new Set(["false", "null", "true"]); + +interface GraphqlState { + inBlockString: boolean; + inString: boolean; +} + +export const graphqlStreamLanguage: StreamParser = { + name: "graphql", + startState: () => ({ + inBlockString: false, + inString: false, + }), + token(stream, state) { + if (state.inBlockString) { + while (!stream.eol()) { + if (stream.match('"""')) { + state.inBlockString = false; + break; + } + stream.next(); + } + + return "string"; + } + + if (state.inString) { + let escaped = false; + + while (!stream.eol()) { + const next = stream.next(); + if (next === '"' && !escaped) { + state.inString = false; + break; + } + escaped = !escaped && next === "\\"; + } + + return "string"; + } + + if (stream.eatSpace()) return null; + + if (stream.match('"""')) { + while (!stream.eol()) { + if (stream.match('"""')) { + return "string"; + } + stream.next(); + } + + state.inBlockString = true; + return "string"; + } + + const next = stream.next(); + if (!next) return null; + + if (next === "#") { + stream.skipToEnd(); + return "comment"; + } + + if (next === '"') { + let escaped = false; + + while (!stream.eol()) { + const char = stream.next(); + if (char === '"' && !escaped) { + return "string"; + } + escaped = !escaped && char === "\\"; + } + + state.inString = true; + return "string"; + } + + if (next === "$") { + stream.eatWhile(/[_0-9A-Za-z]/); + return "variableName"; + } + + if (next === "@") { + stream.eatWhile(/[_0-9A-Za-z]/); + return "meta"; + } + + if (next === "-" && /\d/.test(stream.peek() ?? "")) { + stream.eatWhile(/\d/); + if (stream.peek() === ".") { + stream.next(); + stream.eatWhile(/\d/); + } + return "number"; + } + + if (/\d/.test(next)) { + stream.eatWhile(/\d/); + if (stream.peek() === ".") { + stream.next(); + stream.eatWhile(/\d/); + } + return "number"; + } + + if (/[A-Za-z_]/.test(next)) { + stream.eatWhile(/[_0-9A-Za-z]/); + const word = stream.current(); + + if (GRAPHQL_KEYWORDS.has(word)) return "keyword"; + if (GRAPHQL_ATOMS.has(word)) return "atom"; + return /^[A-Z]/.test(word) ? "typeName" : "variableName"; + } + + return null; + }, + languageData: { + commentTokens: { line: "#" }, + }, +}; + +const MAKEFILE_DIRECTIVES = new Set([ + "-include", + "define", + "else", + "endef", + "endif", + "export", + "ifdef", + "ifndef", + "ifeq", + "ifneq", + "include", + "override", + "private", + "sinclude", + "undefine", + "unexport", + "vpath", +]); + +function escapeRegex(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const MAKEFILE_DIRECTIVE_PATTERN = new RegExp( + `^\\s*(?:${[...MAKEFILE_DIRECTIVES].map(escapeRegex).join("|")})\\b`, +); + +export const makefileStreamLanguage: StreamParser = { + name: "makefile", + token(stream) { + if (stream.sol()) { + if (stream.peek() === "\t") { + stream.skipToEnd(); + return "meta"; + } + + if (stream.match(MAKEFILE_DIRECTIVE_PATTERN)) { + return "keyword"; + } + + if (stream.match(/^\s*[A-Za-z_][A-Za-z0-9_.-]*(?=\s*(?::=|\+=|\?=|=))/)) { + return "variableName"; + } + + if (stream.match(/^\s*[^:=#\s][^:=#]*(?=\s*:)/)) { + return "def"; + } + } + + if (stream.eatSpace()) return null; + + if (stream.match(/^\$\(([^)]+)\)/) || stream.match(/^\$\{([^}]+)\}/)) { + return "variableName"; + } + + const next = stream.next(); + if (!next) return null; + + if (next === "#") { + stream.skipToEnd(); + return "comment"; + } + + if (next === ":" && stream.peek() === "=") { + stream.next(); + return "operator"; + } + + if ((next === "+" || next === "?") && stream.peek() === "=") { + stream.next(); + return "operator"; + } + + if (next === "=") { + return "operator"; + } + + if (/[A-Za-z_.-]/.test(next)) { + stream.eatWhile(/[A-Za-z0-9_.-]/); + return MAKEFILE_DIRECTIVES.has(stream.current()) ? "keyword" : null; + } + + return null; + }, + languageData: { + commentTokens: { line: "#" }, + }, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting.ts new file mode 100644 index 00000000000..bd93300f5cb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting.ts @@ -0,0 +1,70 @@ +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import type { Extension } from "@codemirror/state"; +import { tags } from "@lezer/highlight"; +import { getEditorTheme, type Theme } from "shared/themes"; + +export function getCodeSyntaxHighlighting(theme: Theme): Extension { + const editorTheme = getEditorTheme(theme); + + return syntaxHighlighting( + HighlightStyle.define([ + { + tag: [tags.keyword, tags.operatorKeyword, tags.modifier], + color: editorTheme.syntax.keyword, + }, + { + tag: [tags.comment, tags.lineComment, tags.blockComment], + color: editorTheme.syntax.comment, + fontStyle: "italic", + }, + { + tag: [tags.string, tags.special(tags.string)], + color: editorTheme.syntax.string, + }, + { + tag: [tags.number, tags.integer, tags.float, tags.bool, tags.null], + color: editorTheme.syntax.number, + }, + { + tag: [ + tags.function(tags.variableName), + tags.function(tags.propertyName), + tags.labelName, + ], + color: editorTheme.syntax.functionCall, + }, + { + tag: [tags.variableName, tags.name, tags.propertyName], + color: editorTheme.syntax.variableName, + }, + { + tag: [tags.typeName, tags.definition(tags.typeName)], + color: editorTheme.syntax.typeName, + }, + { + tag: [tags.className], + color: editorTheme.syntax.className, + }, + { + tag: [tags.constant(tags.name), tags.standard(tags.name)], + color: editorTheme.syntax.constant, + }, + { + tag: [tags.regexp, tags.escape, tags.special(tags.regexp)], + color: editorTheme.syntax.regexp, + }, + { + tag: [tags.tagName, tags.angleBracket], + color: editorTheme.syntax.tagName, + }, + { + tag: [tags.attributeName], + color: editorTheme.syntax.attributeName, + }, + { + tag: [tags.invalid], + color: editorTheme.syntax.invalid, + }, + ]), + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts new file mode 100644 index 00000000000..03116cac96e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts @@ -0,0 +1,11 @@ +import type { FileView } from "../../types"; +import { CodeView } from "./CodeView"; + +export const codeView: FileView = { + id: "code", + label: "Code", + match: () => true, + priority: "builtin", + documentKind: "text", + Renderer: CodeView, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 8f2923ed7ae..6dfd06b7ebb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -33,6 +33,7 @@ import { useRecentlyViewedFiles } from "./hooks/useRecentlyViewedFiles"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; +import { FileDocumentStoreProvider } from "./state/fileDocumentStore"; import type { BrowserPaneData, ChatPaneData, @@ -316,7 +317,7 @@ function WorkspaceContent({ useHotkey("QUICK_OPEN", handleQuickOpen); return ( - <> +
- + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx new file mode 100644 index 00000000000..d1a8927860b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx @@ -0,0 +1,25 @@ +import { useWorkspaceClient } from "@superset/workspace-client"; +import { type ReactNode, useEffect } from "react"; +import { + initializeFileDocumentStore, + teardownFileDocumentStore, +} from "./fileDocumentStore"; + +interface FileDocumentStoreProviderProps { + children: ReactNode; +} + +export function FileDocumentStoreProvider({ + children, +}: FileDocumentStoreProviderProps) { + const { trpcClient } = useWorkspaceClient(); + + useEffect(() => { + initializeFileDocumentStore({ trpcClient }); + return () => { + teardownFileDocumentStore(); + }; + }, [trpcClient]); + + return <>{children}; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts new file mode 100644 index 00000000000..2f0a91cab99 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts @@ -0,0 +1,212 @@ +import type { workspaceTrpc } from "@superset/workspace-client"; +import type { ContentState, SaveResult, SharedFileDocument } from "./types"; + +type WorkspaceTrpcClient = ReturnType; + +interface DocumentEntry { + workspaceId: string; + absolutePath: string; + content: ContentState; + savedContentText: string | null; + refCount: number; + version: number; + subscribers: Set<() => void>; +} + +const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; + +let activeTrpcClient: WorkspaceTrpcClient | null = null; +const entries = new Map(); + +function key(workspaceId: string, absolutePath: string): string { + return `${workspaceId}:${absolutePath}`; +} + +function notify(entry: DocumentEntry): void { + entry.version += 1; + for (const listener of entry.subscribers) { + listener(); + } +} + +function computeDirty(entry: DocumentEntry): boolean { + if (entry.content.kind !== "text") return false; + if (entry.savedContentText === null) return false; + return entry.content.value !== entry.savedContentText; +} + +function requireClient(): WorkspaceTrpcClient { + if (!activeTrpcClient) { + throw new Error( + "fileDocumentStore accessed before initialization; ensure FileDocumentStoreProvider is mounted", + ); + } + return activeTrpcClient; +} + +export function initializeFileDocumentStore(config: { + trpcClient: WorkspaceTrpcClient; +}): void { + activeTrpcClient = config.trpcClient; +} + +export function teardownFileDocumentStore(): void { + activeTrpcClient = null; + entries.clear(); +} + +async function loadEntry(entry: DocumentEntry): Promise { + const client = requireClient(); + try { + const result = await client.filesystem.readFile.query({ + workspaceId: entry.workspaceId, + absolutePath: entry.absolutePath, + encoding: "utf-8", + maxBytes: DEFAULT_MAX_BYTES, + }); + + if (result.exceededLimit) { + entry.content = { kind: "too-large" }; + notify(entry); + return; + } + + if (result.kind === "text") { + entry.content = { + kind: "text", + value: result.content, + revision: result.revision, + }; + entry.savedContentText = result.content; + notify(entry); + return; + } + + // PR 1 only renders text. Byte-capable views (image, binary) arrive in PR 2. + // Placeholder value; FilePane gates on `kind === "bytes"` and shows an error state. + entry.content = { + kind: "bytes", + value: new Uint8Array(), + revision: result.revision, + }; + notify(entry); + } catch { + entry.content = { kind: "not-found" }; + notify(entry); + } +} + +function createHandle(entry: DocumentEntry): SharedFileDocument { + return { + get workspaceId() { + return entry.workspaceId; + }, + get absolutePath() { + return entry.absolutePath; + }, + get content() { + return entry.content; + }, + get dirty() { + return computeDirty(entry); + }, + setContent(next) { + if (entry.content.kind !== "text") return; + if (entry.content.value === next) return; + entry.content = { ...entry.content, value: next }; + notify(entry); + }, + async save(opts): Promise { + if (entry.content.kind !== "text") { + return { + status: "error", + error: new Error("Cannot save non-text content"), + }; + } + const client = requireClient(); + const currentValue = entry.content.value; + const currentRevision = entry.content.revision; + try { + const result = await client.filesystem.writeFile.mutate({ + workspaceId: entry.workspaceId, + absolutePath: entry.absolutePath, + content: currentValue, + encoding: "utf-8", + precondition: + opts?.force || !currentRevision + ? undefined + : { ifMatch: currentRevision }, + }); + + if (!result.ok) { + if (result.reason === "conflict") { + return { status: "conflict" }; + } + return { status: result.reason }; + } + + entry.content = { + kind: "text", + value: currentValue, + revision: result.revision, + }; + entry.savedContentText = currentValue; + notify(entry); + return { status: "saved", revision: result.revision }; + } catch (error) { + return { status: "error", error: error as Error }; + } + }, + async reload() { + entry.content = { kind: "loading" }; + entry.savedContentText = null; + notify(entry); + await loadEntry(entry); + }, + subscribe(listener) { + entry.subscribers.add(listener); + return () => { + entry.subscribers.delete(listener); + }; + }, + getVersion() { + return entry.version; + }, + }; +} + +export function acquireDocument( + workspaceId: string, + absolutePath: string, +): SharedFileDocument { + const k = key(workspaceId, absolutePath); + let entry = entries.get(k); + if (!entry) { + entry = { + workspaceId, + absolutePath, + content: { kind: "loading" }, + savedContentText: null, + refCount: 0, + version: 0, + subscribers: new Set(), + }; + entries.set(k, entry); + void loadEntry(entry); + } + entry.refCount += 1; + return createHandle(entry); +} + +export function releaseDocument( + workspaceId: string, + absolutePath: string, +): void { + const k = key(workspaceId, absolutePath); + const entry = entries.get(k); + if (!entry) return; + entry.refCount -= 1; + if (entry.refCount <= 0 && !computeDirty(entry)) { + entries.delete(k); + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts new file mode 100644 index 00000000000..5d17ac2fb51 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts @@ -0,0 +1,9 @@ +export { FileDocumentStoreProvider } from "./FileDocumentStoreProvider"; +export { + acquireDocument, + initializeFileDocumentStore, + releaseDocument, + teardownFileDocumentStore, +} from "./fileDocumentStore"; +export type { ContentState, SaveResult, SharedFileDocument } from "./types"; +export { useSharedFileDocument } from "./useSharedFileDocument"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts new file mode 100644 index 00000000000..35f816393de --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts @@ -0,0 +1,29 @@ +export type ContentState = + | { kind: "loading" } + | { kind: "text"; value: string; revision: string } + | { kind: "bytes"; value: Uint8Array; revision: string } + | { kind: "not-found" } + | { kind: "too-large" } + | { kind: "is-directory" }; + +export type SaveResult = + | { status: "saved"; revision: string } + | { status: "conflict" } + | { status: "not-found" } + | { status: "exists" } + | { status: "error"; error: Error }; + +export interface SharedFileDocument { + readonly workspaceId: string; + readonly absolutePath: string; + + readonly content: ContentState; + readonly dirty: boolean; + + setContent(next: string): void; + save(opts?: { force?: boolean }): Promise; + reload(): Promise; + + subscribe(listener: () => void): () => void; + getVersion(): number; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts new file mode 100644 index 00000000000..f4d9839f670 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts @@ -0,0 +1,27 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { acquireDocument, releaseDocument } from "./fileDocumentStore"; +import type { SharedFileDocument } from "./types"; + +interface UseSharedFileDocumentParams { + workspaceId: string; + absolutePath: string; +} + +export function useSharedFileDocument({ + workspaceId, + absolutePath, +}: UseSharedFileDocumentParams): SharedFileDocument { + const [handle] = useState(() => + acquireDocument(workspaceId, absolutePath), + ); + + useEffect(() => { + return () => { + releaseDocument(workspaceId, absolutePath); + }; + }, [workspaceId, absolutePath]); + + useSyncExternalStore(handle.subscribe, handle.getVersion, handle.getVersion); + + return handle; +} diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx index 0840a644d9e..b0e6c5ed217 100644 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx @@ -10,6 +10,7 @@ const GC_TIME_MS = 30 * 60 * 1_000; export interface WorkspaceClientContextValue { hostUrl: string; queryClient: QueryClient; + trpcClient: ReturnType; getWsToken: () => string | null; } @@ -87,6 +88,7 @@ export function WorkspaceClientProvider({ const contextValue: WorkspaceClientContextValue = { hostUrl: clients.hostUrl, queryClient: clients.queryClient, + trpcClient: clients.trpcClient, getWsToken: clients.getWsToken, }; From 31b5f7f1e9388f5dde424065fde8fceebdf8ad9b Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 12:37:54 -0700 Subject: [PATCH 03/16] =?UTF-8?q?feat(desktop):=20v2=20file=20editor=20sta?= =?UTF-8?q?te=20machine=20+=20chrome=20=E2=80=94=20pendingSave/saveError/c?= =?UTF-8?q?onflict/orphaned/hasExternalChange=20+=20banners=20+=20conflict?= =?UTF-8?q?=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/FilePane/FilePane.tsx | 52 +++++- .../ConflictDialog/ConflictDialog.tsx | 85 +++++++++ .../components/ConflictDialog/index.ts | 1 + .../ExternalChangeBar/ExternalChangeBar.tsx | 2 +- .../OrphanedBanner/OrphanedBanner.tsx | 25 +++ .../components/OrphanedBanner/index.ts | 1 + .../SaveErrorBanner/SaveErrorBanner.tsx | 35 ++++ .../components/SaveErrorBanner/index.ts | 1 + .../fileDocumentStore/fileDocumentStore.ts | 175 ++++++++++++++++-- .../state/fileDocumentStore/index.ts | 9 +- .../state/fileDocumentStore/types.ts | 19 +- 11 files changed, 383 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/ConflictDialog.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/OrphanedBanner.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/SaveErrorBanner.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index 076d4eb3b4d..31e23cf71b6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -2,8 +2,12 @@ import type { RendererContext } from "@superset/panes"; import { useEffect } from "react"; import { useSharedFileDocument } from "../../../../state/fileDocumentStore"; import type { FilePaneData, PaneViewerData } from "../../../../types"; +import { ConflictDialog } from "./components/ConflictDialog"; import { ErrorState } from "./components/ErrorState"; +import { ExternalChangeBar } from "./components/ExternalChangeBar"; import { LoadingState } from "./components/LoadingState"; +import { OrphanedBanner } from "./components/OrphanedBanner"; +import { SaveErrorBanner } from "./components/SaveErrorBanner"; import { pickDefaultView, resolveViews } from "./registry"; interface FilePaneProps { @@ -34,7 +38,7 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { if (document.content.kind === "loading") { return ; } - if (document.content.kind === "not-found") { + if (document.content.kind === "not-found" && !document.orphaned) { return ; } if (document.content.kind === "too-large") { @@ -44,7 +48,7 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { return ; } if (document.content.kind === "bytes") { - // PR 1 does not ship a bytes-capable view. Image/binary views arrive in PR 2. + // PR 1 does not ship a bytes-capable view. Image/binary views arrive in the next commit. return ; } @@ -55,12 +59,46 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { } const ViewRenderer = activeView.Renderer; + const localContent = + document.content.kind === "text" ? document.content.value : ""; return ( - +
+ {document.orphaned && ( + void document.reload()} + /> + )} + {document.hasExternalChange && !document.conflict && ( + void document.reload()} /> + )} + {document.saveError && ( + void document.save()} + onDismiss={() => document.clearSaveError()} + /> + )} +
+ +
+ {document.conflict && ( + void document.resolveConflict("keep")} + onReload={() => void document.resolveConflict("reload")} + onOverwrite={() => void document.resolveConflict("overwrite")} + /> + )} +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/ConflictDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/ConflictDialog.tsx new file mode 100644 index 00000000000..1aa2bdb144a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/ConflictDialog.tsx @@ -0,0 +1,85 @@ +import { MultiFileDiff } from "@pierre/diffs/react"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { useResolvedTheme } from "renderer/stores/theme"; + +interface ConflictDialogProps { + open: boolean; + filePath: string; + localContent: string; + diskContent: string | null; + pendingSave: boolean; + onKeepEditing: () => void; + onReload: () => void; + onOverwrite: () => void; +} + +export function ConflictDialog({ + open, + filePath, + localContent, + diskContent, + pendingSave, + onKeepEditing, + onReload, + onOverwrite, +}: ConflictDialogProps) { + const resolvedTheme = useResolvedTheme(); + const displayDiskContent = diskContent ?? ""; + + return ( + + +
+ + File Changed On Disk + + {diskContent === null + ? `${filePath} was removed or is no longer readable. Review the difference before choosing whether to overwrite it.` + : `${filePath} changed on disk after you started editing. Review the diff before saving.`} + + +
+ +
+ + + + + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/index.ts new file mode 100644 index 00000000000..fac4d338b6a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/index.ts @@ -0,0 +1 @@ +export { ConflictDialog } from "./ConflictDialog"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx index a634c68478c..f5bec2672f0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx @@ -1,5 +1,5 @@ interface ExternalChangeBarProps { - onReload: () => Promise; + onReload: () => Promise | void; } export function ExternalChangeBar({ onReload }: ExternalChangeBarProps) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/OrphanedBanner.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/OrphanedBanner.tsx new file mode 100644 index 00000000000..72af8c53432 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/OrphanedBanner.tsx @@ -0,0 +1,25 @@ +interface OrphanedBannerProps { + dirty: boolean; + onDiscard?: () => void; +} + +export function OrphanedBanner({ dirty, onDiscard }: OrphanedBannerProps) { + return ( +
+ + {dirty + ? "File was deleted on disk. You still have unsaved changes." + : "File was deleted on disk."} + + {dirty && onDiscard && ( + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/index.ts new file mode 100644 index 00000000000..c7cf1a7e8b2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/index.ts @@ -0,0 +1 @@ +export { OrphanedBanner } from "./OrphanedBanner"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/SaveErrorBanner.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/SaveErrorBanner.tsx new file mode 100644 index 00000000000..9e0eee1655e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/SaveErrorBanner.tsx @@ -0,0 +1,35 @@ +interface SaveErrorBannerProps { + message: string; + onRetry?: () => void; + onDismiss?: () => void; +} + +export function SaveErrorBanner({ + message, + onRetry, + onDismiss, +}: SaveErrorBannerProps) { + return ( +
+ Save failed: {message} + {onRetry && ( + + )} + {onDismiss && ( + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/index.ts new file mode 100644 index 00000000000..21957cfac1e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/index.ts @@ -0,0 +1 @@ +export { SaveErrorBanner } from "./SaveErrorBanner"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts index 2f0a91cab99..d3ea0022ea1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts @@ -1,5 +1,11 @@ import type { workspaceTrpc } from "@superset/workspace-client"; -import type { ContentState, SaveResult, SharedFileDocument } from "./types"; +import type { + ConflictResolution, + ConflictState, + ContentState, + SaveResult, + SharedFileDocument, +} from "./types"; type WorkspaceTrpcClient = ReturnType; @@ -8,12 +14,20 @@ interface DocumentEntry { absolutePath: string; content: ContentState; savedContentText: string | null; + pendingSave: boolean; + saveError: Error | null; + conflict: ConflictState | null; + orphaned: boolean; + hasExternalChange: boolean; + isBinary: boolean | null; + byteSize: number | null; refCount: number; version: number; subscribers: Set<() => void>; } const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; +const BINARY_CHECK_SIZE = 8192; let activeTrpcClient: WorkspaceTrpcClient | null = null; const entries = new Map(); @@ -35,6 +49,32 @@ function computeDirty(entry: DocumentEntry): boolean { return entry.content.value !== entry.savedContentText; } +function isBinaryText(content: string): boolean { + const checkLength = Math.min(content.length, BINARY_CHECK_SIZE); + for (let i = 0; i < checkLength; i += 1) { + if (content.charCodeAt(i) === 0) { + return true; + } + } + return false; +} + +function decodeBase64(value: string): Uint8Array { + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(value, "base64")); + } + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function toBytes(value: string | Uint8Array): Uint8Array { + return typeof value === "string" ? decodeBase64(value) : value; +} + function requireClient(): WorkspaceTrpcClient { if (!activeTrpcClient) { throw new Error( @@ -65,6 +105,10 @@ async function loadEntry(entry: DocumentEntry): Promise { maxBytes: DEFAULT_MAX_BYTES, }); + entry.byteSize = result.byteLength; + entry.orphaned = false; + entry.hasExternalChange = false; + if (result.exceededLimit) { entry.content = { kind: "too-large" }; notify(entry); @@ -72,21 +116,30 @@ async function loadEntry(entry: DocumentEntry): Promise { } if (result.kind === "text") { - entry.content = { - kind: "text", - value: result.content, - revision: result.revision, - }; - entry.savedContentText = result.content; + entry.isBinary = isBinaryText(result.content); + if (entry.isBinary) { + entry.content = { + kind: "bytes", + value: new Uint8Array(), + revision: result.revision, + }; + } else { + entry.content = { + kind: "text", + value: result.content, + revision: result.revision, + }; + entry.savedContentText = result.content; + } notify(entry); return; } - // PR 1 only renders text. Byte-capable views (image, binary) arrive in PR 2. - // Placeholder value; FilePane gates on `kind === "bytes"` and shows an error state. + // Raw bytes from host β€” e.g., image files + entry.isBinary = true; entry.content = { kind: "bytes", - value: new Uint8Array(), + value: toBytes(result.content), revision: result.revision, }; notify(entry); @@ -96,6 +149,26 @@ async function loadEntry(entry: DocumentEntry): Promise { } } +async function fetchCurrentDiskContent( + entry: DocumentEntry, +): Promise { + if (entry.isBinary) return null; + const client = requireClient(); + try { + const result = await client.filesystem.readFile.query({ + workspaceId: entry.workspaceId, + absolutePath: entry.absolutePath, + encoding: "utf-8", + maxBytes: DEFAULT_MAX_BYTES, + }); + if (result.kind !== "text" || result.exceededLimit) return null; + if (isBinaryText(result.content)) return null; + return result.content; + } catch { + return null; + } +} + function createHandle(entry: DocumentEntry): SharedFileDocument { return { get workspaceId() { @@ -110,6 +183,27 @@ function createHandle(entry: DocumentEntry): SharedFileDocument { get dirty() { return computeDirty(entry); }, + get pendingSave() { + return entry.pendingSave; + }, + get saveError() { + return entry.saveError; + }, + get conflict() { + return entry.conflict; + }, + get orphaned() { + return entry.orphaned; + }, + get hasExternalChange() { + return entry.hasExternalChange; + }, + get isBinary() { + return entry.isBinary; + }, + get byteSize() { + return entry.byteSize; + }, setContent(next) { if (entry.content.kind !== "text") return; if (entry.content.value === next) return; @@ -126,6 +220,9 @@ function createHandle(entry: DocumentEntry): SharedFileDocument { const client = requireClient(); const currentValue = entry.content.value; const currentRevision = entry.content.revision; + entry.pendingSave = true; + entry.saveError = null; + notify(entry); try { const result = await client.filesystem.writeFile.mutate({ workspaceId: entry.workspaceId, @@ -138,10 +235,17 @@ function createHandle(entry: DocumentEntry): SharedFileDocument { : { ifMatch: currentRevision }, }); + entry.pendingSave = false; + if (!result.ok) { if (result.reason === "conflict") { - return { status: "conflict" }; + const diskContent = await fetchCurrentDiskContent(entry); + entry.conflict = { diskContent }; + entry.hasExternalChange = true; + notify(entry); + return { status: "conflict", diskContent }; } + notify(entry); return { status: result.reason }; } @@ -151,18 +255,47 @@ function createHandle(entry: DocumentEntry): SharedFileDocument { revision: result.revision, }; entry.savedContentText = currentValue; + entry.conflict = null; + entry.hasExternalChange = false; notify(entry); return { status: "saved", revision: result.revision }; } catch (error) { + entry.pendingSave = false; + entry.saveError = error as Error; + notify(entry); return { status: "error", error: error as Error }; } }, async reload() { entry.content = { kind: "loading" }; entry.savedContentText = null; + entry.conflict = null; + entry.hasExternalChange = false; + entry.saveError = null; notify(entry); await loadEntry(entry); }, + async resolveConflict(choice: ConflictResolution) { + if (!entry.conflict) return; + if (choice === "reload") { + await this.reload(); + return; + } + if (choice === "overwrite") { + entry.conflict = null; + notify(entry); + await this.save({ force: true }); + return; + } + // keep β€” dismiss the dialog; buffer stays dirty against stale revision + entry.conflict = null; + notify(entry); + }, + clearSaveError() { + if (entry.saveError === null) return; + entry.saveError = null; + notify(entry); + }, subscribe(listener) { entry.subscribers.add(listener); return () => { @@ -187,6 +320,13 @@ export function acquireDocument( absolutePath, content: { kind: "loading" }, savedContentText: null, + pendingSave: false, + saveError: null, + conflict: null, + orphaned: false, + hasExternalChange: false, + isBinary: null, + byteSize: null, refCount: 0, version: 0, subscribers: new Set(), @@ -206,7 +346,18 @@ export function releaseDocument( const entry = entries.get(k); if (!entry) return; entry.refCount -= 1; - if (entry.refCount <= 0 && !computeDirty(entry)) { + // Block disposal when the buffer has unsaved edits or the file was deleted externally β€” + // matches VS Code's TextFileEditorModelManager.canDispose rule. + if (entry.refCount <= 0 && !computeDirty(entry) && !entry.orphaned) { entries.delete(k); } } + +export function getDocument( + workspaceId: string, + absolutePath: string, +): SharedFileDocument | null { + const entry = entries.get(key(workspaceId, absolutePath)); + if (!entry) return null; + return createHandle(entry); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts index 5d17ac2fb51..7e463a3123f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts @@ -1,9 +1,16 @@ export { FileDocumentStoreProvider } from "./FileDocumentStoreProvider"; export { acquireDocument, + getDocument, initializeFileDocumentStore, releaseDocument, teardownFileDocumentStore, } from "./fileDocumentStore"; -export type { ContentState, SaveResult, SharedFileDocument } from "./types"; +export type { + ConflictResolution, + ConflictState, + ContentState, + SaveResult, + SharedFileDocument, +} from "./types"; export { useSharedFileDocument } from "./useSharedFileDocument"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts index 35f816393de..866500af245 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts @@ -8,11 +8,17 @@ export type ContentState = export type SaveResult = | { status: "saved"; revision: string } - | { status: "conflict" } + | { status: "conflict"; diskContent: string | null } | { status: "not-found" } | { status: "exists" } | { status: "error"; error: Error }; +export type ConflictResolution = "reload" | "overwrite" | "keep"; + +export interface ConflictState { + diskContent: string | null; +} + export interface SharedFileDocument { readonly workspaceId: string; readonly absolutePath: string; @@ -20,9 +26,20 @@ export interface SharedFileDocument { readonly content: ContentState; readonly dirty: boolean; + readonly pendingSave: boolean; + readonly saveError: Error | null; + readonly conflict: ConflictState | null; + readonly orphaned: boolean; + readonly hasExternalChange: boolean; + + readonly isBinary: boolean | null; + readonly byteSize: number | null; + setContent(next: string): void; save(opts?: { force?: boolean }): Promise; reload(): Promise; + resolveConflict(choice: ConflictResolution): Promise; + clearSaveError(): void; subscribe(listener: () => void): () => void; getVersion(): number; From 744b7b801fd0c70e410fb4be001a57cdc6a87d92 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 12:45:05 -0700 Subject: [PATCH 04/16] =?UTF-8?q?feat(desktop):=20v2=20file=20editor=20vie?= =?UTF-8?q?ws=20+=20toggle=20=E2=80=94=20MarkdownPreviewView,=20ImageView,?= =?UTF-8?q?=20BinaryWarningView,=20FileViewToggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/FilePane/FilePane.tsx | 66 ++++++++++++++++--- .../FileViewToggle/FileViewToggle.tsx | 36 ++++++++++ .../components/FileViewToggle/index.ts | 1 + .../components/FilePane/registry/allViews.ts | 12 +++- .../components/FilePane/registry/index.ts | 8 ++- .../FilePane/registry/resolveViews.ts | 6 ++ .../components/FilePane/registry/types.ts | 10 ++- .../BinaryWarningView/BinaryWarningView.tsx | 19 ++++++ .../registry/views/BinaryWarningView/index.ts | 11 ++++ .../FilePane/registry/views/CodeView/index.ts | 3 +- .../registry/views/ImageView/ImageView.tsx | 31 +++++++++ .../registry/views/ImageView/index.ts | 12 ++++ .../MarkdownPreviewView.tsx | 23 +++++++ .../views/MarkdownPreviewView/index.ts | 12 ++++ .../v2-workspace/$workspaceId/types.ts | 2 + 15 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/FileViewToggle.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/BinaryWarningView.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index 31e23cf71b6..1fcffe51284 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -1,14 +1,21 @@ import type { RendererContext } from "@superset/panes"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { useSharedFileDocument } from "../../../../state/fileDocumentStore"; import type { FilePaneData, PaneViewerData } from "../../../../types"; import { ConflictDialog } from "./components/ConflictDialog"; import { ErrorState } from "./components/ErrorState"; import { ExternalChangeBar } from "./components/ExternalChangeBar"; +import { FileViewToggle } from "./components/FileViewToggle"; import { LoadingState } from "./components/LoadingState"; import { OrphanedBanner } from "./components/OrphanedBanner"; import { SaveErrorBanner } from "./components/SaveErrorBanner"; -import { pickDefaultView, resolveViews } from "./registry"; +import { + ALL_VIEWS, + type FileMeta, + orderForToggle, + pickDefaultView, + resolveViews, +} from "./registry"; interface FilePaneProps { context: RendererContext; @@ -34,7 +41,29 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { } }, [document.dirty, data, context.actions]); - // Content gating β€” nothing mounts until the document has renderable content. + const handleChangeView = useCallback( + (viewId: string) => { + context.actions.updateData({ + ...data, + viewId, + } as PaneViewerData); + }, + [context.actions, data], + ); + + const handleForceView = useCallback( + (viewId: string) => { + context.actions.updateData({ + ...data, + forceViewId: viewId, + viewId, + } as PaneViewerData); + }, + [context.actions, data], + ); + + // Content gating β€” LoadingState/ErrorState rendered before view resolution when + // there's nothing for the view to consume. if (document.content.kind === "loading") { return ; } @@ -47,23 +76,40 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { if (document.content.kind === "is-directory") { return ; } - if (document.content.kind === "bytes") { - // PR 1 does not ship a bytes-capable view. Image/binary views arrive in the next commit. - return ; - } - const views = resolveViews(filePath, {}); - const activeView = pickDefaultView(views); + // Resolve which view(s) match the current file. + const meta: FileMeta = { + size: document.byteSize ?? undefined, + isBinary: document.isBinary ?? undefined, + }; + const views = data.forceViewId + ? ALL_VIEWS.filter((v) => v.id === data.forceViewId) + : resolveViews(filePath, meta); + + const activeView = + views.find((v) => v.id === data.viewId) ?? pickDefaultView(views); if (!activeView) { return ; } const ViewRenderer = activeView.Renderer; + const showToggle = views.length > 1 && !data.forceViewId; + const toggleViews = orderForToggle(views); const localContent = document.content.kind === "text" ? document.content.value : ""; return (
+ {showToggle && ( +
+ +
+ )} {document.orphaned && (
{document.conflict && ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/FileViewToggle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/FileViewToggle.tsx new file mode 100644 index 00000000000..7388481ba31 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/FileViewToggle.tsx @@ -0,0 +1,36 @@ +import { cn } from "@superset/ui/utils"; +import { type FileView, resolveViewLabel } from "../../registry"; + +interface FileViewToggleProps { + views: FileView[]; + activeViewId: string; + filePath: string; + onChange: (viewId: string) => void; +} + +export function FileViewToggle({ + views, + activeViewId, + filePath, + onChange, +}: FileViewToggleProps) { + return ( +
+ {views.map((view) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/index.ts new file mode 100644 index 00000000000..61cf4174e71 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/index.ts @@ -0,0 +1 @@ +export { FileViewToggle } from "./FileViewToggle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts index f97d1e4bc55..ed0e9f4e8df 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts @@ -1,6 +1,14 @@ import type { FileView } from "./types"; +import { binaryWarningView } from "./views/BinaryWarningView"; import { codeView } from "./views/CodeView"; +import { imageView } from "./views/ImageView"; +import { markdownPreviewView } from "./views/MarkdownPreviewView"; // Order is preserved as a stable tiebreaker for equal-priority views. -// PR 1 ships only the code view; markdown/image/binary views arrive in later PRs. -export const ALL_VIEWS: FileView[] = [codeView]; +// Exclusives (image, binary-warning) short-circuit resolution when matched. +export const ALL_VIEWS: FileView[] = [ + imageView, + binaryWarningView, + markdownPreviewView, + codeView, +]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts index 9a77c9f02f5..575c73399b6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts @@ -1,10 +1,16 @@ export { ALL_VIEWS } from "./allViews"; -export { pickDefaultView, resolveViews } from "./resolveViews"; +export { + orderForToggle, + pickDefaultView, + resolveViews, +} from "./resolveViews"; export { type DocumentKind, type FileMeta, type FileView, + type FileViewLabel, PRIORITY_RANK, type Priority, + resolveViewLabel, type ViewProps, } from "./types"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts index f9f25789397..f37a67cf92d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts @@ -15,3 +15,9 @@ export function resolveViews(filePath: string, meta: FileMeta): FileView[] { export function pickDefaultView(views: FileView[]): FileView | null { return views[0] ?? null; } + +// Reverse sort order so the default view (index 0) appears on the right of the toggle, +// closest to the editor surface. Matches Cursor's Preview Β· Markdown layout. +export function orderForToggle(views: FileView[]): FileView[] { + return [...views].reverse(); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts index 62bb75c5460..9b1dfdb664c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts @@ -19,9 +19,11 @@ export const PRIORITY_RANK: Record = { option: 1, }; +export type FileViewLabel = string | ((filePath: string) => string); + export interface FileView { id: string; - label: string; + label: FileViewLabel; match: (filePath: string, meta: FileMeta) => boolean; priority: Priority; documentKind: DocumentKind; @@ -32,4 +34,10 @@ export interface ViewProps { document: SharedFileDocument; filePath: string; workspaceId: string; + onChangeView: (viewId: string) => void; + onForceView: (viewId: string) => void; +} + +export function resolveViewLabel(view: FileView, filePath: string): string { + return typeof view.label === "function" ? view.label(filePath) : view.label; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/BinaryWarningView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/BinaryWarningView.tsx new file mode 100644 index 00000000000..06ac4c73e21 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/BinaryWarningView.tsx @@ -0,0 +1,19 @@ +import { Button } from "@superset/ui/button"; +import type { ViewProps } from "../../types"; + +export function BinaryWarningView({ filePath, onForceView }: ViewProps) { + const name = filePath.split(/[/\\]/).pop() ?? filePath; + + return ( +
+
{name}
+
+ This looks like a binary file. Opening it as text may show garbled + output or freeze the editor for large files. +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts new file mode 100644 index 00000000000..9d57bbcd4cb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts @@ -0,0 +1,11 @@ +import type { FileView } from "../../types"; +import { BinaryWarningView } from "./BinaryWarningView"; + +export const binaryWarningView: FileView = { + id: "binary-warning", + label: "Binary", + match: (_, meta) => meta.isBinary === true, + priority: "exclusive", + documentKind: "bytes", + Renderer: BinaryWarningView, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts index 03116cac96e..aba0ef30690 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts @@ -1,9 +1,10 @@ +import { isMarkdownFile } from "shared/file-types"; import type { FileView } from "../../types"; import { CodeView } from "./CodeView"; export const codeView: FileView = { id: "code", - label: "Code", + label: (filePath) => (isMarkdownFile(filePath) ? "Markdown" : "Code"), match: () => true, priority: "builtin", documentKind: "text", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx new file mode 100644 index 00000000000..f52e4c0057a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx @@ -0,0 +1,31 @@ +import { useMemo } from "react"; +import { getImageMimeType } from "shared/file-types"; +import type { ViewProps } from "../../types"; + +export function ImageView({ document, filePath }: ViewProps) { + const dataUrl = useMemo(() => { + if (document.content.kind !== "bytes") return null; + const mimeType = getImageMimeType(filePath) ?? "image/png"; + const base64 = btoa( + Array.from(document.content.value) + .map((b) => String.fromCharCode(b)) + .join(""), + ); + return `data:${mimeType};base64,${base64}`; + }, [document.content, filePath]); + + if (!dataUrl) { + return null; + } + + return ( +
+ {filePath.split("/").pop() +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/index.ts new file mode 100644 index 00000000000..073b17bf911 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/index.ts @@ -0,0 +1,12 @@ +import { isImageFile } from "shared/file-types"; +import type { FileView } from "../../types"; +import { ImageView } from "./ImageView"; + +export const imageView: FileView = { + id: "image", + label: "Image", + match: (filePath) => isImageFile(filePath), + priority: "exclusive", + documentKind: "bytes", + Renderer: ImageView, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx new file mode 100644 index 00000000000..54ba8761fbd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx @@ -0,0 +1,23 @@ +import { TipTapMarkdownRenderer } from "renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer"; +import type { ViewProps } from "../../types"; + +export function MarkdownPreviewView({ document }: ViewProps) { + if (document.content.kind !== "text") { + return null; + } + + return ( +
+ { + if (typeof next === "string") { + document.setContent(next); + } + }} + onSave={() => void document.save()} + /> +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/index.ts new file mode 100644 index 00000000000..a9d4bf9f087 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/index.ts @@ -0,0 +1,12 @@ +import { isMarkdownFile } from "shared/file-types"; +import type { FileView } from "../../types"; +import { MarkdownPreviewView } from "./MarkdownPreviewView"; + +export const markdownPreviewView: FileView = { + id: "markdown-preview", + label: "Preview", + match: (filePath) => isMarkdownFile(filePath), + priority: "option", + documentKind: "text", + Renderer: MarkdownPreviewView, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts index 4cc4cc52fad..29ba2c76171 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts @@ -3,6 +3,8 @@ export interface FilePaneData { mode: "editor" | "diff" | "preview"; hasChanges: boolean; language?: string; + viewId?: string; + forceViewId?: string; } export interface TerminalPaneData { From 93cf059d267642e0e7d1fbcc0700ac21cac72cbb Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 12:47:13 -0700 Subject: [PATCH 05/16] =?UTF-8?q?feat(desktop):=20v2=20file=20editor=20fs:?= =?UTF-8?q?events=20=E2=80=94=20orphan=20detection,=20rename=20tracking,?= =?UTF-8?q?=20external-change=20reload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2-workspace/$workspaceId/page.tsx | 2 +- .../FileDocumentStoreProvider.tsx | 8 +++ .../fileDocumentStore/fileDocumentStore.ts | 58 +++++++++++++++++++ .../state/fileDocumentStore/index.ts | 1 + 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 6dfd06b7ebb..81b7d886144 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -317,7 +317,7 @@ function WorkspaceContent({ useHotkey("QUICK_OPEN", handleQuickOpen); return ( - +
{ + dispatchFsEvent(workspaceId, event); + }); + return <>{children}; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts index d3ea0022ea1..4f72f3a4251 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts @@ -1,4 +1,5 @@ import type { workspaceTrpc } from "@superset/workspace-client"; +import type { FsWatchEvent } from "@superset/workspace-fs/client"; import type { ConflictResolution, ConflictState, @@ -361,3 +362,60 @@ export function getDocument( if (!entry) return null; return createHandle(entry); } + +/** + * Reacts to a workspace file-system event. Called by FileDocumentStoreProvider + * from its `useWorkspaceEvent("fs:events", ...)` subscription. + * + * The @parcel/watcher layer under `packages/workspace-fs/src/watch.ts` already + * coalesces rapid-fire events and pairs delete+create sequences into rename + * events, so a "delete" event here is a real delete β€” no additional debounce + * is required. + */ +export function dispatchFsEvent( + workspaceId: string, + event: FsWatchEvent, +): void { + for (const entry of entries.values()) { + if (entry.workspaceId !== workspaceId) continue; + const affects = + entry.absolutePath === event.absolutePath || + (event.kind === "rename" && event.oldAbsolutePath === entry.absolutePath); + if (!affects) continue; + + switch (event.kind) { + case "delete": { + entry.orphaned = true; + notify(entry); + break; + } + case "rename": { + // Migrate the entry to its new absolute path + const oldKey = key(entry.workspaceId, entry.absolutePath); + entries.delete(oldKey); + entry.absolutePath = event.absolutePath; + entries.set(key(entry.workspaceId, entry.absolutePath), entry); + if (computeDirty(entry)) { + entry.hasExternalChange = true; + } + notify(entry); + break; + } + case "create": + case "update": + case "overflow": { + // Clear orphan if the file reappeared + if (entry.orphaned) { + entry.orphaned = false; + } + if (computeDirty(entry)) { + entry.hasExternalChange = true; + notify(entry); + } else { + void loadEntry(entry); + } + break; + } + } + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts index 7e463a3123f..779acd06cf7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts @@ -1,6 +1,7 @@ export { FileDocumentStoreProvider } from "./FileDocumentStoreProvider"; export { acquireDocument, + dispatchFsEvent, getDocument, initializeFileDocumentStore, releaseDocument, From 8a72129719a8e80ecc0397d9e94feb702464720d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 12:48:09 -0700 Subject: [PATCH 06/16] =?UTF-8?q?feat(desktop):=20v2=20file=20editor=20clo?= =?UTF-8?q?se-pane=20save=20guard=20=E2=80=94=20wire=20Save=20action=20via?= =?UTF-8?q?=20non-hook=20fileDocumentStore.getDocument?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index e964935d8c2..65e20035239 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -28,6 +28,7 @@ import { useHotkeyDisplay } from "renderer/hotkeys"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import { useSettings } from "renderer/stores/settings"; +import { getDocument } from "../../state/fileDocumentStore"; import type { BrowserPaneData, ChatPaneData, @@ -155,9 +156,17 @@ export function usePaneRegistry( actions: [ { label: "Save", - onClick: () => { - // TODO: wire up save via editor ref - resolve(true); + onClick: async () => { + const doc = getDocument(workspaceId, data.filePath); + if (!doc) { + resolve(true); + return; + } + const result = await doc.save(); + // Only proceed to close if the save succeeded; otherwise + // leave the pane open so the user can see the conflict / + // error state and retry. + resolve(result.status === "saved"); }, }, { From 2ceb0b2e5526d5cf72e5e03f1088486bdacc40c4 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 12:48:51 -0700 Subject: [PATCH 07/16] =?UTF-8?q?chore(desktop):=20remove=20superseded=20v?= =?UTF-8?q?2=20file=20editor=20code=20=E2=80=94=20old=20renderers=20+=20ho?= =?UTF-8?q?st-service=20useFileDocument?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../host-service/useFileDocument/index.ts | 5 - .../useFileDocument/useFileDocument.ts | 323 ------------------ .../renderers/CodeRenderer/CodeRenderer.tsx | 59 ---- .../FilePane/renderers/CodeRenderer/index.ts | 1 - .../renderers/ImageRenderer/ImageRenderer.tsx | 30 -- .../FilePane/renderers/ImageRenderer/index.ts | 1 - .../MarkdownRenderer/MarkdownRenderer.tsx | 97 ------ .../renderers/MarkdownRenderer/index.ts | 5 - 8 files changed, 521 deletions(-) delete mode 100644 apps/desktop/src/renderer/hooks/host-service/useFileDocument/index.ts delete mode 100644 apps/desktop/src/renderer/hooks/host-service/useFileDocument/useFileDocument.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/ImageRenderer.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/MarkdownRenderer.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/index.ts diff --git a/apps/desktop/src/renderer/hooks/host-service/useFileDocument/index.ts b/apps/desktop/src/renderer/hooks/host-service/useFileDocument/index.ts deleted file mode 100644 index c2f6f7bc64c..00000000000 --- a/apps/desktop/src/renderer/hooks/host-service/useFileDocument/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - type UseFileDocumentParams, - type UseFileDocumentResult, - useFileDocument, -} from "./useFileDocument"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useFileDocument/useFileDocument.ts b/apps/desktop/src/renderer/hooks/host-service/useFileDocument/useFileDocument.ts deleted file mode 100644 index cf9de5de59f..00000000000 --- a/apps/desktop/src/renderer/hooks/host-service/useFileDocument/useFileDocument.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { workspaceTrpc } from "@superset/workspace-client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useWorkspaceEvent } from "../useWorkspaceEvent"; - -const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; -const BINARY_CHECK_SIZE = 8192; - -export interface UseFileDocumentParams { - workspaceId: string; - absolutePath: string; - mode?: "auto" | "text" | "bytes"; - maxBytes?: number; - hasLocalChanges?: boolean; - autoReloadWhenClean?: boolean; -} - -export interface UseFileDocumentResult { - absolutePath: string; - state: - | { kind: "loading" } - | { kind: "not-found" } - | { kind: "binary" } - | { kind: "too-large" } - | { kind: "text"; content: string; revision: string } - | { kind: "bytes"; content: Uint8Array; revision: string }; - save: (input: { - content: string | Uint8Array; - force?: boolean; - }) => Promise< - | { status: "saved"; revision: string } - | { status: "conflict"; currentContent: string | null } - | { status: "not-found" } - | { status: "exists" } - >; - reload: () => Promise; - hasExternalChange: boolean; - conflict: { diskContent: string | null } | null; -} - -function isBinaryText(content: string): boolean { - const checkLength = Math.min(content.length, BINARY_CHECK_SIZE); - for (let index = 0; index < checkLength; index += 1) { - if (content.charCodeAt(index) === 0) { - return true; - } - } - - return false; -} - -function encodeBase64(content: Uint8Array): string { - if (typeof Buffer !== "undefined") { - return Buffer.from(content).toString("base64"); - } - - let binary = ""; - for (const byte of content) { - binary += String.fromCharCode(byte); - } - return btoa(binary); -} - -function decodeBase64(content: string): Uint8Array { - if (typeof Buffer !== "undefined") { - return new Uint8Array(Buffer.from(content, "base64")); - } - - const binary = atob(content); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes; -} - -export function useFileDocument({ - workspaceId, - absolutePath, - mode = "auto", - maxBytes = DEFAULT_MAX_BYTES, - hasLocalChanges = false, - autoReloadWhenClean = true, -}: UseFileDocumentParams): UseFileDocumentResult { - const utils = workspaceTrpc.useUtils(); - const [currentPath, setCurrentPath] = useState(absolutePath); - const [hasExternalChange, setHasExternalChange] = useState(false); - const [conflict, setConflict] = useState<{ - diskContent: string | null; - } | null>(null); - const currentPathRef = useRef(currentPath); - currentPathRef.current = currentPath; - const hasLocalChangesRef = useRef(hasLocalChanges); - hasLocalChangesRef.current = hasLocalChanges; - - useEffect(() => { - setCurrentPath(absolutePath); - setHasExternalChange(false); - setConflict(null); - }, [absolutePath]); - - const readFileQuery = workspaceTrpc.filesystem.readFile.useQuery( - { - workspaceId, - absolutePath: currentPath, - encoding: mode === "bytes" ? undefined : "utf-8", - maxBytes, - }, - { - enabled: Boolean(workspaceId && currentPath), - retry: false, - refetchOnWindowFocus: false, - }, - ); - - const revision = useMemo(() => { - if (!readFileQuery.data) { - return null; - } - - return readFileQuery.data.revision; - }, [readFileQuery.data]); - - const reload = useCallback(async (): Promise => { - setHasExternalChange(false); - setConflict(null); - await readFileQuery.refetch(); - }, [readFileQuery]); - - const fetchCurrentDiskContent = useCallback(async (): Promise< - string | null - > => { - try { - const result = await utils.filesystem.readFile.fetch({ - workspaceId, - absolutePath: currentPathRef.current, - encoding: "utf-8", - maxBytes, - }); - - if ( - result.kind !== "text" || - result.exceededLimit || - isBinaryText(result.content) - ) { - return null; - } - - return result.content; - } catch { - return null; - } - }, [maxBytes, utils.filesystem.readFile, workspaceId]); - - const markExternalChange = useCallback(async (): Promise => { - setHasExternalChange(true); - if (mode === "bytes") { - setConflict({ diskContent: null }); - return; - } - - const diskContent = await fetchCurrentDiskContent(); - setConflict({ diskContent }); - }, [fetchCurrentDiskContent, mode]); - - useWorkspaceEvent( - "fs:events", - workspaceId, - (event) => { - const path = currentPathRef.current; - if (!path) { - return; - } - - if (event.kind === "overflow") { - if (hasLocalChangesRef.current) { - void markExternalChange(); - return; - } - - if (autoReloadWhenClean) { - void reload(); - } - return; - } - - if (event.kind === "rename" && event.oldAbsolutePath === path) { - setCurrentPath(event.absolutePath); - if (hasLocalChangesRef.current) { - void markExternalChange(); - return; - } - if (autoReloadWhenClean) { - setHasExternalChange(false); - setConflict(null); - } - return; - } - - if (event.absolutePath !== path) { - return; - } - - if (hasLocalChangesRef.current) { - void markExternalChange(); - return; - } - - if (autoReloadWhenClean) { - void reload(); - } - }, - Boolean(workspaceId && currentPath), - ); - - const saveMutation = workspaceTrpc.filesystem.writeFile.useMutation(); - - const save = useCallback( - async (input: { content: string | Uint8Array; force?: boolean }) => { - const content = - typeof input.content === "string" - ? input.content - : { - kind: "base64" as const, - data: encodeBase64(input.content), - }; - - const result = await saveMutation.mutateAsync({ - workspaceId, - absolutePath: currentPathRef.current, - content, - encoding: typeof input.content === "string" ? "utf-8" : undefined, - precondition: - input.force || !revision - ? undefined - : { - ifMatch: revision, - }, - }); - - if (!result.ok) { - if (result.reason === "conflict") { - const currentContent = await fetchCurrentDiskContent(); - setHasExternalChange(true); - setConflict({ diskContent: currentContent }); - return { - status: "conflict" as const, - currentContent, - }; - } - - return { - status: result.reason, - } as const; - } - - setHasExternalChange(false); - setConflict(null); - await utils.filesystem.readFile.invalidate({ - workspaceId, - absolutePath: currentPathRef.current, - }); - await readFileQuery.refetch(); - return { - status: "saved" as const, - revision: result.revision, - }; - }, - [ - fetchCurrentDiskContent, - readFileQuery, - revision, - saveMutation, - utils.filesystem.readFile, - workspaceId, - ], - ); - - const state = useMemo(() => { - if (readFileQuery.error) { - return { kind: "not-found" }; - } - - if (readFileQuery.isPending || !readFileQuery.data) { - return { kind: "loading" }; - } - - if (readFileQuery.data.exceededLimit) { - return { kind: "too-large" }; - } - - if (mode === "bytes" || readFileQuery.data.kind === "bytes") { - const bytes = - typeof readFileQuery.data.content === "string" - ? decodeBase64(readFileQuery.data.content) - : readFileQuery.data.content; - return { - kind: "bytes", - content: bytes, - revision: readFileQuery.data.revision, - }; - } - - const textContent = readFileQuery.data.content; - if (mode === "auto" && isBinaryText(textContent)) { - return { kind: "binary" }; - } - - return { - kind: "text", - content: textContent, - revision: readFileQuery.data.revision, - }; - }, [mode, readFileQuery.data, readFileQuery.error, readFileQuery.isPending]); - - return { - absolutePath: currentPath, - state, - save, - reload, - hasExternalChange, - conflict, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx deleted file mode 100644 index 662386d2d35..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import { CodeEditor } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor"; -import { detectLanguage } from "shared/detect-language"; -import { ExternalChangeBar } from "../../components/ExternalChangeBar"; - -interface CodeRendererProps { - content: string; - filePath: string; - hasExternalChange: boolean; - onDirtyChange: (dirty: boolean) => void; - onReload: () => Promise; - onSave: (content: string) => Promise; -} - -export function CodeRenderer({ - content, - filePath, - hasExternalChange, - onDirtyChange, - onReload, - onSave, -}: CodeRendererProps) { - const language = detectLanguage(filePath); - const currentContentRef = useRef(content); - const [savedContent, setSavedContent] = useState(content); - - // Track the initial/saved content to detect dirty state - if (content !== savedContent && !onDirtyChange) { - setSavedContent(content); - } - - const handleChange = useCallback( - (value: string) => { - currentContentRef.current = value; - onDirtyChange(value !== savedContent); - }, - [onDirtyChange, savedContent], - ); - - const handleSave = useCallback(async () => { - await onSave(currentContentRef.current); - setSavedContent(currentContentRef.current); - }, [onSave]); - - return ( -
- {hasExternalChange && } -
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/index.ts deleted file mode 100644 index f3eecc8cfa8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CodeRenderer } from "./CodeRenderer"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/ImageRenderer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/ImageRenderer.tsx deleted file mode 100644 index 75e77780927..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/ImageRenderer.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useMemo } from "react"; -import { getImageMimeType } from "shared/file-types"; - -interface ImageRendererProps { - content: Uint8Array; - filePath: string; -} - -export function ImageRenderer({ content, filePath }: ImageRendererProps) { - const dataUrl = useMemo(() => { - const mimeType = getImageMimeType(filePath) ?? "image/png"; - const base64 = btoa( - Array.from(content) - .map((b) => String.fromCharCode(b)) - .join(""), - ); - return `data:${mimeType};base64,${base64}`; - }, [content, filePath]); - - return ( -
- {filePath.split("/").pop() -
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/index.ts deleted file mode 100644 index d61a1b37f40..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/ImageRenderer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ImageRenderer } from "./ImageRenderer"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/MarkdownRenderer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/MarkdownRenderer.tsx deleted file mode 100644 index 77a25aa3279..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/MarkdownRenderer.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import { TipTapMarkdownRenderer } from "renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer"; -import { CodeEditor } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor"; -import { ExternalChangeBar } from "../../components/ExternalChangeBar"; - -export type MarkdownViewMode = "rendered" | "raw"; - -interface MarkdownRendererProps { - content: string; - hasExternalChange: boolean; - onDirtyChange: (dirty: boolean) => void; - onReload: () => Promise; - onSave: (content: string) => Promise; -} - -export function MarkdownRenderer({ - content, - hasExternalChange, - onDirtyChange, - onReload, - onSave, -}: MarkdownRendererProps) { - const [viewMode, _setViewMode] = useState("rendered"); - const currentContentRef = useRef(content); - const [savedContent, setSavedContent] = useState(content); - - const handleChange = useCallback( - (value: string) => { - currentContentRef.current = value; - onDirtyChange(value !== savedContent); - }, - [onDirtyChange, savedContent], - ); - - const handleSave = useCallback(async () => { - await onSave(currentContentRef.current); - setSavedContent(currentContentRef.current); - }, [onSave]); - - return ( -
- {hasExternalChange && } -
- {viewMode === "rendered" ? ( -
- -
- ) : ( - - )} -
-
- ); -} - -// Exported for use in renderHeaderExtras -export type { MarkdownViewMode as ViewMode }; - -interface ViewModeToggleProps { - viewMode: MarkdownViewMode; - onViewModeChange: (mode: MarkdownViewMode) => void; -} - -export function MarkdownViewModeToggle({ - viewMode, - onViewModeChange, -}: ViewModeToggleProps) { - return ( -
- - -
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/index.ts deleted file mode 100644 index eb4c7b8f970..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/MarkdownRenderer/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - MarkdownRenderer, - type MarkdownViewMode, - MarkdownViewModeToggle, -} from "./MarkdownRenderer"; From ae573a42cc7ebdd3e6be07c47ffb76d9a252599c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 13:05:19 -0700 Subject: [PATCH 08/16] feat(desktop): move v2 file pane view toggle into the tab header via renderHeaderExtras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also inline FileIcon inside renderTitle so the file icon keeps rendering β€” the default pane header uses `titleContent ?? (icon + title)`, and upstream 3dd1de2e8 introduced the custom renderTitle for italic name + dirty dot without including the icon, suppressing it. --- .../components/FilePane/FilePane.tsx | 16 +---- .../FilePaneHeaderExtras.tsx | 64 +++++++++++++++++++ .../components/FilePaneHeaderExtras/index.ts | 1 + .../hooks/usePaneRegistry/usePaneRegistry.tsx | 5 ++ 4 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index 1fcffe51284..f3c90342e30 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -5,14 +5,12 @@ import type { FilePaneData, PaneViewerData } from "../../../../types"; import { ConflictDialog } from "./components/ConflictDialog"; import { ErrorState } from "./components/ErrorState"; import { ExternalChangeBar } from "./components/ExternalChangeBar"; -import { FileViewToggle } from "./components/FileViewToggle"; import { LoadingState } from "./components/LoadingState"; import { OrphanedBanner } from "./components/OrphanedBanner"; import { SaveErrorBanner } from "./components/SaveErrorBanner"; import { ALL_VIEWS, type FileMeta, - orderForToggle, pickDefaultView, resolveViews, } from "./registry"; @@ -78,6 +76,8 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { } // Resolve which view(s) match the current file. + // The same resolution runs in FilePaneHeaderExtras β€” the toggle and active view + // stay in lockstep because both observe the same pane data + document state. const meta: FileMeta = { size: document.byteSize ?? undefined, isBinary: document.isBinary ?? undefined, @@ -93,23 +93,11 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { } const ViewRenderer = activeView.Renderer; - const showToggle = views.length > 1 && !data.forceViewId; - const toggleViews = orderForToggle(views); const localContent = document.content.kind === "text" ? document.content.value : ""; return (
- {showToggle && ( -
- -
- )} {document.orphaned && ( ; + workspaceId: string; +} + +export function FilePaneHeaderExtras({ + context, + workspaceId, +}: FilePaneHeaderExtrasProps) { + const data = context.pane.data as FilePaneData; + const { filePath } = data; + + const document = useSharedFileDocument({ + workspaceId, + absolutePath: filePath, + }); + + const handleChangeView = useCallback( + (viewId: string) => { + context.actions.updateData({ + ...data, + viewId, + } as PaneViewerData); + }, + [context.actions, data], + ); + + // Same resolution as FilePane body, so the toggle and the active renderer stay in lockstep. + const meta: FileMeta = { + size: document.byteSize ?? undefined, + isBinary: document.isBinary ?? undefined, + }; + const views = data.forceViewId + ? ALL_VIEWS.filter((v) => v.id === data.forceViewId) + : resolveViews(filePath, meta); + + if (views.length <= 1 || data.forceViewId) return null; + + const activeView = + views.find((v) => v.id === data.viewId) ?? pickDefaultView(views); + if (!activeView) return null; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/index.ts new file mode 100644 index 00000000000..c240958abec --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/index.ts @@ -0,0 +1 @@ +export { FilePaneHeaderExtras } from "./FilePaneHeaderExtras"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 65e20035239..c4f88ef0bcf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -47,6 +47,7 @@ import { ChatPane } from "./components/ChatPane"; import { CommentPane } from "./components/CommentPane"; import { DiffPane } from "./components/DiffPane"; import { FilePane } from "./components/FilePane"; +import { FilePaneHeaderExtras } from "./components/FilePane/components/FilePaneHeaderExtras"; import { TerminalPane } from "./components/TerminalPane"; function getFileName(filePath: string): string { @@ -131,6 +132,7 @@ export function usePaneRegistry( const name = getFileName(data.filePath); return (
+ {name} @@ -143,6 +145,9 @@ export function usePaneRegistry( renderPane: (ctx: RendererContext) => ( ), + renderHeaderExtras: (ctx: RendererContext) => ( + + ), onHeaderClick: (ctx: RendererContext) => ctx.actions.pin(), onBeforeClose: (pane) => { From d8d676f306b6a8c6aba1e3fe333aef1cd9ccfa3f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 19:55:03 -0700 Subject: [PATCH 09/16] =?UTF-8?q?feat(desktop):=20v2=20file=20editor=20vis?= =?UTF-8?q?ual=20polish=20=E2=80=94=20Lucide=20fold=20chevrons/placeholder?= =?UTF-8?q?,=20contour=20selection,=20palette-native=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fold markers now render as Lucide chevron SVGs via foldGutter's markerDOM, aligned per-line-box regardless of font metrics. Hidden at rest, fades in on gutter hover. - Fold placeholder (collapsed block) renders as a Lucide MoreHorizontal button via codeFolding's placeholderDOM, with rounded corners + subtle hover background. - Custom selection layer (contourSelectionLayer): per-line RectangleMarkers snug to each line's actual text width, extending 4px past the last character, with a half-line-height stub for empty middle lines. Consecutive rects abut exactly. CM's default .cm-selectionBackground hidden β€” our layer is the only painter. - Selection background and active-line highlight share one palette-derived token (foreground at ~3% alpha), so they read as the same visual weight. Active-line hidden while a selection is active (selectionClassTogglePlugin toggles .cm-hasSelection on the editor root). - Dropped the gutter/content separator border. Line numbers get more left padding, less right padding now that there's no vertical rule. - @replit/codemirror-css-color-picker adds inline swatches on CSS color literals. - Exported withAlpha from shared/themes so editor theme can derive low-alpha variants without hardcoded rgba. --- apps/desktop/package.json | 1 + .../components/CodeEditor/CodeEditor.tsx | 186 ++++++++++++++++-- .../CodeEditor/createCodeMirrorTheme.ts | 100 +++++++++- apps/desktop/src/shared/themes/index.ts | 1 + bun.lock | 3 + 5 files changed, 272 insertions(+), 19 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 35595338586..367d4c62e6a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -76,6 +76,7 @@ "@pierre/diffs": "1.1.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@replit/codemirror-css-color-picker": "^6.3.0", "@sentry/electron": "^7.7.0", "@streamdown/mermaid": "1.0.2", "@superset/auth": "workspace:*", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx index d1a7d06713b..817133d4c1e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx @@ -4,9 +4,15 @@ import { historyKeymap, indentWithTab, } from "@codemirror/commands"; -import { bracketMatching, indentOnInput } from "@codemirror/language"; +import { + bracketMatching, + codeFolding, + foldGutter, + foldKeymap, + indentOnInput, +} from "@codemirror/language"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; -import { Compartment, EditorState } from "@codemirror/state"; +import { Compartment, EditorSelection, EditorState } from "@codemirror/state"; import { drawSelection, dropCursor, @@ -15,8 +21,14 @@ import { highlightActiveLineGutter, highlightSpecialChars, keymap, + type LayerMarker, + layer, lineNumbers, + RectangleMarker, + ViewPlugin, + type ViewUpdate, } from "@codemirror/view"; +import { colorPicker } from "@replit/codemirror-css-color-picker"; import { cn } from "@superset/ui/utils"; import { useQuery } from "@tanstack/react-query"; import { type MutableRefObject, useEffect, useRef } from "react"; @@ -41,6 +53,152 @@ interface CodeEditorProps { onSave?: () => void; } +// Lucide chevron paths, inlined so we return a plain HTMLElement (foldGutter's +// markerDOM contract) without bridging React. Matches lucide-react's ChevronDown +// and ChevronRight exactly. +const CHEVRON_DOWN_PATH = "m6 9 6 6 6-6"; +const CHEVRON_RIGHT_PATH = "m9 18 6-6-6-6"; + +function buildFoldChevron(open: boolean): HTMLElement { + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + el.setAttribute("viewBox", "0 0 24 24"); + el.setAttribute("fill", "none"); + el.setAttribute("stroke", "currentColor"); + el.setAttribute("stroke-width", "2"); + el.setAttribute("stroke-linecap", "round"); + el.setAttribute("stroke-linejoin", "round"); + el.setAttribute("class", "cm-foldChevron"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", open ? CHEVRON_DOWN_PATH : CHEVRON_RIGHT_PATH); + el.appendChild(path); + return el as unknown as HTMLElement; +} + +// Toggle a class on the editor root when any selection range is non-empty, so +// CSS can suppress the active-line highlight while a selection is drawn. +const selectionClassTogglePlugin = ViewPlugin.fromClass( + class { + constructor(view: EditorView) { + this.sync(view); + } + update(update: ViewUpdate) { + if (update.selectionSet || update.docChanged) { + this.sync(update.view); + } + } + sync(view: EditorView) { + const hasSelection = view.state.selection.ranges.some((r) => !r.empty); + view.dom.classList.toggle("cm-hasSelection", hasSelection); + } + }, +); + +// Custom selection layer: draws selection backgrounds per-line, snug to each +// line's actual text instead of CM's default full-line-width fill for middle +// lines of multi-line selections. +// +// We keep drawSelection() for cursor rendering (including multi-cursor); its +// own .cm-selectionBackground rectangles are hidden via CSS so this layer is +// the only thing painting selection backgrounds. +const contourSelectionLayer = layer({ + above: false, + class: "cm-contourSelectionLayer", + update(update) { + return ( + update.docChanged || + update.viewportChanged || + update.selectionSet || + update.geometryChanged + ); + }, + markers(view) { + const markers: LayerMarker[] = []; + const lineHeight = view.defaultLineHeight; + for (const range of view.state.selection.ranges) { + if (range.empty) continue; + const fromLine = view.state.doc.lineAt(range.from); + const toLine = view.state.doc.lineAt(range.to); + const TRAILING_PAD = 4; + // Half-height-wide stub for empty lines in the middle of a selection + // so the selection stays visually contiguous through blank lines. + const EMPTY_LINE_WIDTH = Math.round(lineHeight / 2); + for (let n = fromLine.number; n <= toLine.number; n += 1) { + const line = view.state.doc.line(n); + const selStart = Math.max(range.from, line.from); + // Clamp selection end to actual text end so trailing whitespace + // space past the last visible character is never filled. + const textEnd = line.from + line.text.length; + const selEnd = Math.min(range.to, textEnd); + const isEmpty = selStart >= selEnd; + const isMiddleLine = n > fromLine.number && n < toLine.number; + // Skip edge lines that fall in empty territory (selection starts at + // end-of-line or ends at start-of-line); only show the stub for + // genuinely empty middle lines. + if (isEmpty && !isMiddleLine) continue; + const lineRange = isEmpty + ? EditorSelection.cursor(line.from) + : EditorSelection.range(selStart, selEnd); + for (const m of RectangleMarker.forRange( + view, + "cm-contourSelection", + lineRange, + )) { + // Expand each rect to fill the full line-cell height. Use exactly + // lineHeight (no +1) so consecutive rects abut without overlap β€” + // overlap darkens at transparent fill alphas into a visible stripe. + const gap = Math.max(0, lineHeight - m.height); + const width = isEmpty + ? EMPTY_LINE_WIDTH + : (m.width ?? 0) + TRAILING_PAD; + markers.push( + new RectangleMarker( + "cm-contourSelection", + m.left, + m.top - gap / 2, + width, + lineHeight, + ), + ); + } + } + } + return markers; + }, +}); + +// Lucide MoreHorizontal (three dots) β€” inline SVG built imperatively so we can +// return a plain HTMLElement to CM's placeholderDOM contract. +function buildFoldPlaceholder( + _view: unknown, + onclick: (event: Event) => void, +): HTMLElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "cm-foldPlaceholder"; + button.setAttribute("aria-label", "Unfold"); + button.addEventListener("click", onclick); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.setAttribute("class", "cm-foldPlaceholderIcon"); + for (const cx of ["5", "12", "19"]) { + const c = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + c.setAttribute("cx", cx); + c.setAttribute("cy", "12"); + c.setAttribute("r", "1"); + svg.appendChild(c); + } + button.appendChild(svg); + return button; +} + export function CodeEditor({ value, language, @@ -99,6 +257,15 @@ export function CodeEditor({ highlightActiveLineGutter(), highlightSpecialChars(), history(), + // Render fold markers as Lucide SVGs rather than Unicode glyphs β€” + // text glyphs have font-dependent baselines that refuse to align + // with line numbers. SVGs have exact bounding boxes and scale + // predictably. Hover reveal is handled in createCodeMirrorTheme. + foldGutter({ markerDOM: buildFoldChevron }), + // Collapsed-block placeholder uses Lucide MoreHorizontal. Lives + // in a separate codeFolding() β€” its config facet combines with + // the one registered internally by foldGutter(). + codeFolding({ placeholderDOM: buildFoldPlaceholder }), drawSelection(), dropCursor(), EditorState.allowMultipleSelections.of(true), @@ -106,7 +273,9 @@ export function CodeEditor({ bracketMatching(), highlightActiveLine(), highlightSelectionMatches(), - EditorView.lineWrapping, + colorPicker, + contourSelectionLayer, + selectionClassTogglePlugin, editableCompartment.of([ EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly), @@ -119,16 +288,14 @@ export function CodeEditor({ ...defaultKeymap, ...historyKeymap, ...searchKeymap, + ...foldKeymap, ]), saveKeymap, themeCompartment.of([ getCodeSyntaxHighlighting(activeTheme), createCodeMirrorTheme( activeTheme, - { - fontFamily: editorFontFamily, - fontSize: editorFontSize, - }, + { fontFamily: editorFontFamily, fontSize: editorFontSize }, fillHeight, ), ]), @@ -188,10 +355,7 @@ export function CodeEditor({ getCodeSyntaxHighlighting(activeTheme), createCodeMirrorTheme( activeTheme, - { - fontFamily: editorFontFamily, - fontSize: editorFontSize, - }, + { fontFamily: editorFontFamily, fontSize: editorFontSize }, fillHeight, ), ]), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts index 4d9b64a85c4..755ee02fb0b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts @@ -1,5 +1,5 @@ import { EditorView } from "@codemirror/view"; -import { getEditorTheme, type Theme } from "shared/themes"; +import { getEditorTheme, type Theme, withAlpha } from "shared/themes"; import { DEFAULT_CODE_EDITOR_FONT_FAMILY, DEFAULT_CODE_EDITOR_FONT_SIZE, @@ -18,6 +18,14 @@ export function createCodeMirrorTheme( const fontSize = fontSettings.fontSize ?? DEFAULT_CODE_EDITOR_FONT_SIZE; const lineHeight = Math.round(fontSize * 1.5); const editorTheme = getEditorTheme(theme); + // Shared subtle overlay for both active-line highlight and selection so + // they read as the same visual weight β€” foreground at low alpha, derived + // from the app palette. + const lineHighlightBackground = withAlpha( + theme.ui.foreground, + theme.type === "dark" ? 0.025 : 0.04, + ); + const selectionBackground = lineHighlightBackground; return EditorView.theme( { @@ -43,18 +51,94 @@ export function createCodeMirrorTheme( ".cm-gutters": { backgroundColor: editorTheme.colors.gutterBackground, color: editorTheme.colors.gutterForeground, - borderRight: `1px solid ${editorTheme.colors.border}`, + border: "none", }, - ".cm-activeLine": { + // Line numbers: more breathing room on the left edge, tighter on the + // right since the gutter/content separator is gone. + ".cm-lineNumbers .cm-gutterElement": { + padding: "0 2px 0 8px", + }, + // Fold placeholder (Lucide MoreHorizontal rendered when a block is + // collapsed). Reset button defaults, match our rounded / theme look, + // and add a mild hover state. + ".cm-foldPlaceholder": { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: editorTheme.colors.panel, + border: `1px solid ${editorTheme.colors.border}`, + color: editorTheme.colors.gutterForeground, + borderRadius: "4px", + margin: "0 2px", + padding: "0 3px", + height: `${Math.max(14, lineHeight - 4)}px`, + cursor: "pointer", + verticalAlign: "middle", + transition: "background-color 120ms ease", + }, + ".cm-foldPlaceholder:hover": { backgroundColor: editorTheme.colors.activeLine, }, + ".cm-foldPlaceholderIcon": { + width: "12px", + height: "12px", + display: "block", + }, + // Anchor every gutter cell to the editor's line-height so fold + // chevrons share a row box with the digit line numbers. + ".cm-gutterElement": { + lineHeight: `${lineHeight}px`, + }, + // Fold chevron: render the SVG centered in its cell, transparent by + // default, fade in when the user hovers the gutter (group-hover). + ".cm-foldGutter .cm-gutterElement": { + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "0 2px", + }, + ".cm-foldChevron": { + width: "12px", + height: "12px", + display: "block", + opacity: 0, + transition: "opacity 260ms ease", + }, + ".cm-gutters:hover .cm-foldChevron": { + opacity: 1, + }, + // Pointer cursor on foldable rows (they're the ones that render a chevron). + ".cm-foldGutter .cm-gutterElement:has(.cm-foldChevron)": { + cursor: "pointer", + }, + ".cm-activeLine": { + backgroundColor: lineHighlightBackground, + }, ".cm-activeLineGutter": { - backgroundColor: editorTheme.colors.activeLine, + backgroundColor: lineHighlightBackground, + }, + // Suppress the active-line highlight while a selection is active β€” + // the selectionClassTogglePlugin adds .cm-hasSelection to the editor + // root whenever any selection range is non-empty. + "&.cm-hasSelection .cm-activeLine": { + backgroundColor: "transparent", + }, + "&.cm-hasSelection .cm-activeLineGutter": { + backgroundColor: "transparent", + }, + // Hide CM's default per-line-width selection rectangles β€” our + // contourSelectionLayer (in CodeEditor.tsx) paints per-line rects + // snug to actual text so trailing whitespace on middle lines of a + // multi-line selection isn't filled. + ".cm-selectionBackground": { + display: "none", + }, + ".cm-contourSelection": { + backgroundColor: selectionBackground, + }, + ".cm-content ::selection": { + backgroundColor: selectionBackground, }, - "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": - { - backgroundColor: editorTheme.colors.selection, - }, ".cm-selectionMatch": { backgroundColor: editorTheme.colors.search, }, diff --git a/apps/desktop/src/shared/themes/index.ts b/apps/desktop/src/shared/themes/index.ts index 7de01dbb6a7..dcd057c3f3c 100644 --- a/apps/desktop/src/shared/themes/index.ts +++ b/apps/desktop/src/shared/themes/index.ts @@ -26,3 +26,4 @@ export { getDefaultTerminalColors, getTerminalColors, } from "./types"; +export { withAlpha } from "./utils"; diff --git a/bun.lock b/bun.lock index 28e0c771e2e..4528d2ce224 100644 --- a/bun.lock +++ b/bun.lock @@ -153,6 +153,7 @@ "@pierre/diffs": "1.1.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@replit/codemirror-css-color-picker": "^6.3.0", "@sentry/electron": "^7.7.0", "@streamdown/mermaid": "1.0.2", "@superset/auth": "workspace:*", @@ -2165,6 +2166,8 @@ "@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="], + "@replit/codemirror-css-color-picker": ["@replit/codemirror-css-color-picker@6.3.0", "", { "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-19biDANghUm7Fz7L1SNMIhK48tagaWuCOHj4oPPxc7hxPGkTVY2lU/jVZ8tsbTKQPVG7BO2CBDzs7CBwb20t4A=="], + "@rn-primitives/accordion": ["@rn-primitives/accordion@1.4.0", "", { "dependencies": { "@radix-ui/react-accordion": "^1.2.12", "@rn-primitives/hooks": "1.4.0", "@rn-primitives/slot": "1.4.0", "@rn-primitives/types": "1.4.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-3oXnEILYiitkzGeZ2SM2Ux3aE+sy4/0Ug4AGO5Ac6ChqGdQS0yUKSEatlVNRb1/NhIyipacPS4hIYVv7bo7BJA=="], "@rn-primitives/alert-dialog": ["@rn-primitives/alert-dialog@1.4.0", "", { "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", "@rn-primitives/hooks": "1.4.0", "@rn-primitives/slot": "1.4.0", "@rn-primitives/types": "1.4.0" }, "peerDependencies": { "@rn-primitives/portal": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-TLnFbdOR1gqofJliMgLbm8A3liHAX0gTsLQyqG/aSVgSXSHNSGlO5H7WMcmaWcBe6vJgbR1UYIV3ADMHbzu+mA=="], From fbfab76ab80c15de85c283bc1f623ff056dbabb7 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 20:24:07 -0700 Subject: [PATCH 10/16] refactor(desktop): organize v2 CodeEditor into folder-per-module + dedupe view resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per AGENTS.md "one folder per module with barrel index.ts": - Extract inline helpers from CodeEditor.tsx into sibling extension folders: extensions/foldChevron, foldPlaceholder, contourSelectionLayer, selectionClassTogglePlugin. Hoists TRAILING_PAD / EMPTY_LINE_WIDTH_RATIO to module scope alongside their layer. - Move loose CodeMirror utility modules into their own folders: CodeEditorAdapter/, createCodeMirrorTheme/, loadLanguageSupport/ (with streamLanguages co-located as its only consumer), syntax-highlighting/. - Add registry/resolveActivePaneView/ β€” shared helper consumed by FilePane and FilePaneHeaderExtras to compute views + active view identically. Kills ~12 lines of duplication and removes one class of desync bugs. CodeEditor.tsx drops from 425 β†’ 256 lines, focused purely on the React component. --- .../components/FilePane/FilePane.tsx | 23 +-- .../FilePaneHeaderExtras.tsx | 20 +-- .../components/FilePane/registry/index.ts | 4 + .../registry/resolveActivePaneView/index.ts | 4 + .../resolveActivePaneView.ts | 31 ++++ .../components/CodeEditor/CodeEditor.tsx | 164 +----------------- .../CodeEditorAdapter.ts | 0 .../CodeEditor/CodeEditorAdapter/index.ts | 5 + .../createCodeMirrorTheme.ts | 2 +- .../CodeEditor/createCodeMirrorTheme/index.ts | 1 + .../contourSelectionLayer.ts | 80 +++++++++ .../extensions/contourSelectionLayer/index.ts | 1 + .../extensions/foldChevron/foldChevron.ts | 21 +++ .../extensions/foldChevron/index.ts | 1 + .../foldPlaceholder/foldPlaceholder.ts | 33 ++++ .../extensions/foldPlaceholder/index.ts | 1 + .../selectionClassTogglePlugin/index.ts | 1 + .../selectionClassTogglePlugin.ts | 20 +++ .../CodeEditor/loadLanguageSupport/index.ts | 1 + .../loadLanguageSupport.ts | 0 .../streamLanguages.ts | 0 .../CodeEditor/syntax-highlighting/index.ts | 1 + .../syntax-highlighting.ts | 0 23 files changed, 217 insertions(+), 197 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/resolveActivePaneView.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/{ => CodeEditorAdapter}/CodeEditorAdapter.ts (100%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/index.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/{ => createCodeMirrorTheme}/createCodeMirrorTheme.ts (99%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/contourSelectionLayer.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/foldChevron.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/foldPlaceholder.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/selectionClassTogglePlugin.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/index.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/{ => loadLanguageSupport}/loadLanguageSupport.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/{ => loadLanguageSupport}/streamLanguages.ts (100%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/index.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/{ => syntax-highlighting}/syntax-highlighting.ts (100%) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index f3c90342e30..dd6f76635e9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -8,12 +8,7 @@ import { ExternalChangeBar } from "./components/ExternalChangeBar"; import { LoadingState } from "./components/LoadingState"; import { OrphanedBanner } from "./components/OrphanedBanner"; import { SaveErrorBanner } from "./components/SaveErrorBanner"; -import { - ALL_VIEWS, - type FileMeta, - pickDefaultView, - resolveViews, -} from "./registry"; +import { resolveActivePaneView } from "./registry"; interface FilePaneProps { context: RendererContext; @@ -75,19 +70,9 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { return ; } - // Resolve which view(s) match the current file. - // The same resolution runs in FilePaneHeaderExtras β€” the toggle and active view - // stay in lockstep because both observe the same pane data + document state. - const meta: FileMeta = { - size: document.byteSize ?? undefined, - isBinary: document.isBinary ?? undefined, - }; - const views = data.forceViewId - ? ALL_VIEWS.filter((v) => v.id === data.forceViewId) - : resolveViews(filePath, meta); - - const activeView = - views.find((v) => v.id === data.viewId) ?? pickDefaultView(views); + // The same resolution runs in FilePaneHeaderExtras β€” toggle + active view + // stay in lockstep because both observe the same pane data + document. + const { activeView } = resolveActivePaneView(document, data); if (!activeView) { return ; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx index 37986912c9f..0958cc7e829 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx @@ -2,13 +2,7 @@ import type { RendererContext } from "@superset/panes"; import { useCallback } from "react"; import { useSharedFileDocument } from "../../../../../../state/fileDocumentStore"; import type { FilePaneData, PaneViewerData } from "../../../../../../types"; -import { - ALL_VIEWS, - type FileMeta, - orderForToggle, - pickDefaultView, - resolveViews, -} from "../../registry"; +import { orderForToggle, resolveActivePaneView } from "../../registry"; import { FileViewToggle } from "../FileViewToggle"; interface FilePaneHeaderExtrasProps { @@ -38,19 +32,9 @@ export function FilePaneHeaderExtras({ [context.actions, data], ); - // Same resolution as FilePane body, so the toggle and the active renderer stay in lockstep. - const meta: FileMeta = { - size: document.byteSize ?? undefined, - isBinary: document.isBinary ?? undefined, - }; - const views = data.forceViewId - ? ALL_VIEWS.filter((v) => v.id === data.forceViewId) - : resolveViews(filePath, meta); + const { views, activeView } = resolveActivePaneView(document, data); if (views.length <= 1 || data.forceViewId) return null; - - const activeView = - views.find((v) => v.id === data.viewId) ?? pickDefaultView(views); if (!activeView) return null; return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts index 575c73399b6..74ad75150c1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts @@ -1,4 +1,8 @@ export { ALL_VIEWS } from "./allViews"; +export { + type ActivePaneView, + resolveActivePaneView, +} from "./resolveActivePaneView"; export { orderForToggle, pickDefaultView, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/index.ts new file mode 100644 index 00000000000..955dea094cc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/index.ts @@ -0,0 +1,4 @@ +export { + type ActivePaneView, + resolveActivePaneView, +} from "./resolveActivePaneView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/resolveActivePaneView.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/resolveActivePaneView.ts new file mode 100644 index 00000000000..51b01357fc1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/resolveActivePaneView.ts @@ -0,0 +1,31 @@ +import type { SharedFileDocument } from "../../../../../../state/fileDocumentStore"; +import type { FilePaneData } from "../../../../../../types"; +import { ALL_VIEWS } from "../allViews"; +import { pickDefaultView, resolveViews } from "../resolveViews"; +import type { FileMeta, FileView } from "../types"; + +export interface ActivePaneView { + views: FileView[]; + activeView: FileView | null; +} + +/** + * Resolve the list of views available for a given pane's file plus the one + * currently active. Consumed by both the FilePane body and FilePaneHeaderExtras + * so the toggle and the rendered view stay in lockstep. + */ +export function resolveActivePaneView( + document: SharedFileDocument, + data: FilePaneData, +): ActivePaneView { + const meta: FileMeta = { + size: document.byteSize ?? undefined, + isBinary: document.isBinary ?? undefined, + }; + const views = data.forceViewId + ? ALL_VIEWS.filter((v) => v.id === data.forceViewId) + : resolveViews(data.filePath, meta); + const activeView = + views.find((v) => v.id === data.viewId) ?? pickDefaultView(views); + return { views, activeView }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx index 817133d4c1e..e47e58fa9c8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx @@ -12,7 +12,7 @@ import { indentOnInput, } from "@codemirror/language"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; -import { Compartment, EditorSelection, EditorState } from "@codemirror/state"; +import { Compartment, EditorState } from "@codemirror/state"; import { drawSelection, dropCursor, @@ -21,12 +21,7 @@ import { highlightActiveLineGutter, highlightSpecialChars, keymap, - type LayerMarker, - layer, lineNumbers, - RectangleMarker, - ViewPlugin, - type ViewUpdate, } from "@codemirror/view"; import { colorPicker } from "@replit/codemirror-css-color-picker"; import { cn } from "@superset/ui/utils"; @@ -39,6 +34,10 @@ import { createCodeMirrorAdapter, } from "./CodeEditorAdapter"; import { createCodeMirrorTheme } from "./createCodeMirrorTheme"; +import { contourSelectionLayer } from "./extensions/contourSelectionLayer"; +import { buildFoldChevron } from "./extensions/foldChevron"; +import { buildFoldPlaceholder } from "./extensions/foldPlaceholder"; +import { selectionClassTogglePlugin } from "./extensions/selectionClassTogglePlugin"; import { loadLanguageSupport } from "./loadLanguageSupport"; import { getCodeSyntaxHighlighting } from "./syntax-highlighting"; @@ -53,152 +52,6 @@ interface CodeEditorProps { onSave?: () => void; } -// Lucide chevron paths, inlined so we return a plain HTMLElement (foldGutter's -// markerDOM contract) without bridging React. Matches lucide-react's ChevronDown -// and ChevronRight exactly. -const CHEVRON_DOWN_PATH = "m6 9 6 6 6-6"; -const CHEVRON_RIGHT_PATH = "m9 18 6-6-6-6"; - -function buildFoldChevron(open: boolean): HTMLElement { - const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - el.setAttribute("xmlns", "http://www.w3.org/2000/svg"); - el.setAttribute("viewBox", "0 0 24 24"); - el.setAttribute("fill", "none"); - el.setAttribute("stroke", "currentColor"); - el.setAttribute("stroke-width", "2"); - el.setAttribute("stroke-linecap", "round"); - el.setAttribute("stroke-linejoin", "round"); - el.setAttribute("class", "cm-foldChevron"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("d", open ? CHEVRON_DOWN_PATH : CHEVRON_RIGHT_PATH); - el.appendChild(path); - return el as unknown as HTMLElement; -} - -// Toggle a class on the editor root when any selection range is non-empty, so -// CSS can suppress the active-line highlight while a selection is drawn. -const selectionClassTogglePlugin = ViewPlugin.fromClass( - class { - constructor(view: EditorView) { - this.sync(view); - } - update(update: ViewUpdate) { - if (update.selectionSet || update.docChanged) { - this.sync(update.view); - } - } - sync(view: EditorView) { - const hasSelection = view.state.selection.ranges.some((r) => !r.empty); - view.dom.classList.toggle("cm-hasSelection", hasSelection); - } - }, -); - -// Custom selection layer: draws selection backgrounds per-line, snug to each -// line's actual text instead of CM's default full-line-width fill for middle -// lines of multi-line selections. -// -// We keep drawSelection() for cursor rendering (including multi-cursor); its -// own .cm-selectionBackground rectangles are hidden via CSS so this layer is -// the only thing painting selection backgrounds. -const contourSelectionLayer = layer({ - above: false, - class: "cm-contourSelectionLayer", - update(update) { - return ( - update.docChanged || - update.viewportChanged || - update.selectionSet || - update.geometryChanged - ); - }, - markers(view) { - const markers: LayerMarker[] = []; - const lineHeight = view.defaultLineHeight; - for (const range of view.state.selection.ranges) { - if (range.empty) continue; - const fromLine = view.state.doc.lineAt(range.from); - const toLine = view.state.doc.lineAt(range.to); - const TRAILING_PAD = 4; - // Half-height-wide stub for empty lines in the middle of a selection - // so the selection stays visually contiguous through blank lines. - const EMPTY_LINE_WIDTH = Math.round(lineHeight / 2); - for (let n = fromLine.number; n <= toLine.number; n += 1) { - const line = view.state.doc.line(n); - const selStart = Math.max(range.from, line.from); - // Clamp selection end to actual text end so trailing whitespace - // space past the last visible character is never filled. - const textEnd = line.from + line.text.length; - const selEnd = Math.min(range.to, textEnd); - const isEmpty = selStart >= selEnd; - const isMiddleLine = n > fromLine.number && n < toLine.number; - // Skip edge lines that fall in empty territory (selection starts at - // end-of-line or ends at start-of-line); only show the stub for - // genuinely empty middle lines. - if (isEmpty && !isMiddleLine) continue; - const lineRange = isEmpty - ? EditorSelection.cursor(line.from) - : EditorSelection.range(selStart, selEnd); - for (const m of RectangleMarker.forRange( - view, - "cm-contourSelection", - lineRange, - )) { - // Expand each rect to fill the full line-cell height. Use exactly - // lineHeight (no +1) so consecutive rects abut without overlap β€” - // overlap darkens at transparent fill alphas into a visible stripe. - const gap = Math.max(0, lineHeight - m.height); - const width = isEmpty - ? EMPTY_LINE_WIDTH - : (m.width ?? 0) + TRAILING_PAD; - markers.push( - new RectangleMarker( - "cm-contourSelection", - m.left, - m.top - gap / 2, - width, - lineHeight, - ), - ); - } - } - } - return markers; - }, -}); - -// Lucide MoreHorizontal (three dots) β€” inline SVG built imperatively so we can -// return a plain HTMLElement to CM's placeholderDOM contract. -function buildFoldPlaceholder( - _view: unknown, - onclick: (event: Event) => void, -): HTMLElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "cm-foldPlaceholder"; - button.setAttribute("aria-label", "Unfold"); - button.addEventListener("click", onclick); - - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("fill", "none"); - svg.setAttribute("stroke", "currentColor"); - svg.setAttribute("stroke-width", "2"); - svg.setAttribute("stroke-linecap", "round"); - svg.setAttribute("stroke-linejoin", "round"); - svg.setAttribute("class", "cm-foldPlaceholderIcon"); - for (const cx of ["5", "12", "19"]) { - const c = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - c.setAttribute("cx", cx); - c.setAttribute("cy", "12"); - c.setAttribute("r", "1"); - svg.appendChild(c); - } - button.appendChild(svg); - return button; -} - export function CodeEditor({ value, language, @@ -257,14 +110,7 @@ export function CodeEditor({ highlightActiveLineGutter(), highlightSpecialChars(), history(), - // Render fold markers as Lucide SVGs rather than Unicode glyphs β€” - // text glyphs have font-dependent baselines that refuse to align - // with line numbers. SVGs have exact bounding boxes and scale - // predictably. Hover reveal is handled in createCodeMirrorTheme. foldGutter({ markerDOM: buildFoldChevron }), - // Collapsed-block placeholder uses Lucide MoreHorizontal. Lives - // in a separate codeFolding() β€” its config facet combines with - // the one registered internally by foldGutter(). codeFolding({ placeholderDOM: buildFoldPlaceholder }), drawSelection(), dropCursor(), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/CodeEditorAdapter.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/CodeEditorAdapter.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/index.ts new file mode 100644 index 00000000000..aaa547716b1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/index.ts @@ -0,0 +1,5 @@ +export { + type CodeEditorAdapter, + createCodeMirrorAdapter, + type EditorSelectionLines, +} from "./CodeEditorAdapter"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts similarity index 99% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts index 755ee02fb0b..311584a9ac6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts @@ -3,7 +3,7 @@ import { getEditorTheme, type Theme, withAlpha } from "shared/themes"; import { DEFAULT_CODE_EDITOR_FONT_FAMILY, DEFAULT_CODE_EDITOR_FONT_SIZE, -} from "./constants"; +} from "../constants"; interface CodeEditorFontSettings { fontFamily?: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/index.ts new file mode 100644 index 00000000000..a2bd8a30ec9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/index.ts @@ -0,0 +1 @@ +export { createCodeMirrorTheme } from "./createCodeMirrorTheme"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/contourSelectionLayer.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/contourSelectionLayer.ts new file mode 100644 index 00000000000..a48e6895696 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/contourSelectionLayer.ts @@ -0,0 +1,80 @@ +import { EditorSelection } from "@codemirror/state"; +import { type LayerMarker, layer, RectangleMarker } from "@codemirror/view"; + +// How far past the last character each line's selection rect extends, so the +// selection breathes on the right edge instead of cutting flush with the text. +const TRAILING_PAD = 4; + +// Half-line-height-wide stub for empty lines in the middle of a selection so +// the selection reads as contiguous across blank gaps. +const EMPTY_LINE_WIDTH_RATIO = 0.5; + +// Custom selection layer: draws selection backgrounds per-line, snug to each +// line's actual text instead of CM's default full-line-width fill for middle +// lines of multi-line selections. +// +// We keep drawSelection() for cursor rendering (including multi-cursor); its +// own .cm-selectionBackground rectangles are hidden via CSS so this layer is +// the only thing painting selection backgrounds. +export const contourSelectionLayer = layer({ + above: false, + class: "cm-contourSelectionLayer", + update(update) { + return ( + update.docChanged || + update.viewportChanged || + update.selectionSet || + update.geometryChanged + ); + }, + markers(view) { + const markers: LayerMarker[] = []; + const lineHeight = view.defaultLineHeight; + const emptyLineWidth = Math.round(lineHeight * EMPTY_LINE_WIDTH_RATIO); + for (const range of view.state.selection.ranges) { + if (range.empty) continue; + const fromLine = view.state.doc.lineAt(range.from); + const toLine = view.state.doc.lineAt(range.to); + for (let n = fromLine.number; n <= toLine.number; n += 1) { + const line = view.state.doc.line(n); + const selStart = Math.max(range.from, line.from); + // Clamp selection end to actual text end so trailing whitespace + // space past the last visible character is never filled. + const textEnd = line.from + line.text.length; + const selEnd = Math.min(range.to, textEnd); + const isEmpty = selStart >= selEnd; + const isMiddleLine = n > fromLine.number && n < toLine.number; + // Skip edge lines that fall in empty territory (selection starts at + // end-of-line or ends at start-of-line); only show the stub for + // genuinely empty middle lines. + if (isEmpty && !isMiddleLine) continue; + const lineRange = isEmpty + ? EditorSelection.cursor(line.from) + : EditorSelection.range(selStart, selEnd); + for (const m of RectangleMarker.forRange( + view, + "cm-contourSelection", + lineRange, + )) { + // Expand each rect to fill the full line-cell height. Use exactly + // lineHeight (no +1) so consecutive rects abut without overlap β€” + // overlap darkens at transparent fill alphas into a visible stripe. + const gap = Math.max(0, lineHeight - m.height); + const width = isEmpty + ? emptyLineWidth + : (m.width ?? 0) + TRAILING_PAD; + markers.push( + new RectangleMarker( + "cm-contourSelection", + m.left, + m.top - gap / 2, + width, + lineHeight, + ), + ); + } + } + } + return markers; + }, +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/index.ts new file mode 100644 index 00000000000..559a3412154 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/index.ts @@ -0,0 +1 @@ +export { contourSelectionLayer } from "./contourSelectionLayer"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/foldChevron.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/foldChevron.ts new file mode 100644 index 00000000000..5ff977a806a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/foldChevron.ts @@ -0,0 +1,21 @@ +// Lucide chevron paths, inlined so we return a plain HTMLElement (foldGutter's +// markerDOM contract) without bridging React. Matches lucide-react's +// ChevronDown and ChevronRight exactly. +const CHEVRON_DOWN_PATH = "m6 9 6 6 6-6"; +const CHEVRON_RIGHT_PATH = "m9 18 6-6-6-6"; + +export function buildFoldChevron(open: boolean): HTMLElement { + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + el.setAttribute("viewBox", "0 0 24 24"); + el.setAttribute("fill", "none"); + el.setAttribute("stroke", "currentColor"); + el.setAttribute("stroke-width", "2"); + el.setAttribute("stroke-linecap", "round"); + el.setAttribute("stroke-linejoin", "round"); + el.setAttribute("class", "cm-foldChevron"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", open ? CHEVRON_DOWN_PATH : CHEVRON_RIGHT_PATH); + el.appendChild(path); + return el as unknown as HTMLElement; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/index.ts new file mode 100644 index 00000000000..a5636efbe81 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/index.ts @@ -0,0 +1 @@ +export { buildFoldChevron } from "./foldChevron"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/foldPlaceholder.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/foldPlaceholder.ts new file mode 100644 index 00000000000..e467c7f5787 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/foldPlaceholder.ts @@ -0,0 +1,33 @@ +import type { EditorView } from "@codemirror/view"; + +// Lucide MoreHorizontal (three dots) β€” inline SVG built imperatively so we can +// return a plain HTMLElement to CM's placeholderDOM contract. +export function buildFoldPlaceholder( + _view: EditorView, + onclick: (event: Event) => void, +): HTMLElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "cm-foldPlaceholder"; + button.setAttribute("aria-label", "Unfold"); + button.addEventListener("click", onclick); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.setAttribute("class", "cm-foldPlaceholderIcon"); + for (const cx of ["5", "12", "19"]) { + const c = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + c.setAttribute("cx", cx); + c.setAttribute("cy", "12"); + c.setAttribute("r", "1"); + svg.appendChild(c); + } + button.appendChild(svg); + return button; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/index.ts new file mode 100644 index 00000000000..6086ba73e2f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/index.ts @@ -0,0 +1 @@ +export { buildFoldPlaceholder } from "./foldPlaceholder"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/index.ts new file mode 100644 index 00000000000..575cc376cd9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/index.ts @@ -0,0 +1 @@ +export { selectionClassTogglePlugin } from "./selectionClassTogglePlugin"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/selectionClassTogglePlugin.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/selectionClassTogglePlugin.ts new file mode 100644 index 00000000000..96024dd89ff --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/selectionClassTogglePlugin.ts @@ -0,0 +1,20 @@ +import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; + +// Toggle a class on the editor root when any selection range is non-empty, so +// CSS can suppress the active-line highlight while a selection is drawn. +export const selectionClassTogglePlugin = ViewPlugin.fromClass( + class { + constructor(view: EditorView) { + this.sync(view); + } + update(update: ViewUpdate) { + if (update.selectionSet || update.docChanged) { + this.sync(update.view); + } + } + sync(view: EditorView) { + const hasSelection = view.state.selection.ranges.some((r) => !r.empty); + view.dom.classList.toggle("cm-hasSelection", hasSelection); + } + }, +); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/index.ts new file mode 100644 index 00000000000..1b9171751c1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/index.ts @@ -0,0 +1 @@ +export { loadLanguageSupport } from "./loadLanguageSupport"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/loadLanguageSupport.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/loadLanguageSupport.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/streamLanguages.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/streamLanguages.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/streamLanguages.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/streamLanguages.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/index.ts new file mode 100644 index 00000000000..8d6313f9e34 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/index.ts @@ -0,0 +1 @@ +export { getCodeSyntaxHighlighting } from "./syntax-highlighting"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/syntax-highlighting.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/syntax-highlighting.ts From 443096c413f0958774689b5a34ddff827efc1de4 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 20:28:11 -0700 Subject: [PATCH 11/16] refactor(desktop): move resolveActivePaneView under registry/utils/ AGENTS.md groups shared utility functions under utils/; relocate so the registry matches the convention. --- .../components/FilePane/registry/index.ts | 8 ++++---- .../{ => utils}/resolveActivePaneView/index.ts | 0 .../resolveActivePaneView/resolveActivePaneView.ts | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/{ => utils}/resolveActivePaneView/index.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/{ => utils}/resolveActivePaneView/resolveActivePaneView.ts (71%) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts index 74ad75150c1..e67fc0a79bb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts @@ -1,8 +1,4 @@ export { ALL_VIEWS } from "./allViews"; -export { - type ActivePaneView, - resolveActivePaneView, -} from "./resolveActivePaneView"; export { orderForToggle, pickDefaultView, @@ -18,3 +14,7 @@ export { resolveViewLabel, type ViewProps, } from "./types"; +export { + type ActivePaneView, + resolveActivePaneView, +} from "./utils/resolveActivePaneView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/resolveActivePaneView.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/resolveActivePaneView.ts similarity index 71% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/resolveActivePaneView.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/resolveActivePaneView.ts index 51b01357fc1..35ab6840f01 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveActivePaneView/resolveActivePaneView.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/resolveActivePaneView.ts @@ -1,8 +1,8 @@ -import type { SharedFileDocument } from "../../../../../../state/fileDocumentStore"; -import type { FilePaneData } from "../../../../../../types"; -import { ALL_VIEWS } from "../allViews"; -import { pickDefaultView, resolveViews } from "../resolveViews"; -import type { FileMeta, FileView } from "../types"; +import type { SharedFileDocument } from "../../../../../../../state/fileDocumentStore"; +import type { FilePaneData } from "../../../../../../../types"; +import { ALL_VIEWS } from "../../allViews"; +import { pickDefaultView, resolveViews } from "../../resolveViews"; +import type { FileMeta, FileView } from "../../types"; export interface ActivePaneView { views: FileView[]; From 655a64c45ceb444d3431de9d416e0dca57ffa1cd Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 23:09:07 -0700 Subject: [PATCH 12/16] =?UTF-8?q?feat(desktop):=20v2=20file=20editor=20sta?= =?UTF-8?q?bility=20pass=20=E2=80=94=20rename=20tracking,=20save=20guards,?= =?UTF-8?q?=20conflict=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store passes trpcClient per-entry at acquire time so there's no global init race on hard reload; removes activeTrpcClient + requireClient. - Rename-safe acquire/release: iterate entries via snapshot to avoid mid-iteration map-insert loops; detect atomic-save renames (target matches open entry) and treat as content update; reuse handle when the entry was already migrated to the new path to avoid refcount leaks. - Preview-pane retarget: useSharedFileDocument now swaps handles on workspaceId/absolutePath prop changes via setState-during-render so the view never shows a stale file. - FilePane auto-follows renames by reconciling data.filePath from document.absolutePath, and auto-pins on first dirty transition. - Dirty state no longer mirrored into pane data; the tab title reads document.dirty live via a small FilePaneTabTitle component, which eliminates the store-cascade on first keystroke. Drop hasChanges from FilePaneData, and switch onBeforeClose / onBeforeCloseTab to read getDocument(ws, path)?.dirty. - Stable entry.id exposed on SharedFileDocument; CodeEditor keys on it so rename doesn't remount the editor and undo history is preserved. - Rename no longer flags hasExternalChange β€” a path change isn't a content change; disk conflicts continue to surface at save time. - Replace ExternalChangeBar + MultiFileDiff ConflictDialog with a VS Code-style alert (Save / Don't Save / Cancel) fired from FilePane. - CLOSE_TERMINAL usages in v2 swapped to CLOSE_PANE; the hotkey handler now runs the pane registry's onBeforeClose before closing. - Copy Path switches from electronTrpc to navigator.clipboard so it works when the host service is remote. - Active-line highlight bumped and switched to accent token; Cmd+Shift+/ vacated for the editor's built-in comment toggle on Cmd+/. --- .../src/renderer/hooks/useCopyToClipboard.ts | 15 +-- apps/desktop/src/renderer/hotkeys/registry.ts | 2 +- .../useDefaultContextMenuActions.tsx | 2 +- .../components/FilePane/FilePane.tsx | 62 +++++++---- .../ConflictDialog/ConflictDialog.tsx | 85 --------------- .../components/ConflictDialog/index.ts | 1 - .../ExternalChangeBar/ExternalChangeBar.tsx | 18 ---- .../components/ExternalChangeBar/index.ts | 1 - .../registry/views/CodeView/CodeView.tsx | 2 +- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 48 ++++++--- .../useWorkspaceHotkeys.ts | 13 ++- .../v2-workspace/$workspaceId/page.tsx | 26 +++-- .../FileDocumentStoreProvider.tsx | 18 +--- .../fileDocumentStore/fileDocumentStore.ts | 101 ++++++++---------- .../state/fileDocumentStore/index.ts | 2 - .../state/fileDocumentStore/types.ts | 1 + .../useSharedFileDocument.ts | 45 +++++++- .../v2-workspace/$workspaceId/types.ts | 1 - .../desktop/src/shared/themes/editor-theme.ts | 5 +- 19 files changed, 196 insertions(+), 252 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/ConflictDialog.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/index.ts diff --git a/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts b/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts index f8329d908be..b9766364ec1 100644 --- a/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts +++ b/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts @@ -1,26 +1,15 @@ import { useCallback, useState } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -/** - * Copy text to clipboard via Electron's native clipboard API (IPC). - * - * Unlike `navigator.clipboard.writeText`, this works regardless of - * document focus β€” no DOMException when a terminal or webview has focus. - * - * Returns `{ copyToClipboard, copied }` where `copied` is true for - * `timeout` ms after a successful write. - */ export function useCopyToClipboard(timeout = 2000) { - const { mutateAsync } = electronTrpc.external.copyPath.useMutation(); const [copied, setCopied] = useState(false); const copyToClipboard = useCallback( async (text: string) => { - await mutateAsync(text); + await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), timeout); }, - [mutateAsync, timeout], + [timeout], ); return { copyToClipboard, copied }; diff --git a/apps/desktop/src/renderer/hotkeys/registry.ts b/apps/desktop/src/renderer/hotkeys/registry.ts index 60c6cab2f40..27d19256aa6 100644 --- a/apps/desktop/src/renderer/hotkeys/registry.ts +++ b/apps/desktop/src/renderer/hotkeys/registry.ts @@ -564,7 +564,7 @@ export const HOTKEYS_REGISTRY = { }, SHOW_HOTKEYS: { key: { - mac: "meta+slash", + mac: "meta+shift+slash", windows: "ctrl+shift+slash", linux: "ctrl+shift+slash", }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx index 20fda20fd90..43804012141 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx @@ -33,7 +33,7 @@ export function useDefaultContextMenuActions( const equalizePaneSplitsShortcut = useHotkeyDisplay( "EQUALIZE_PANE_SPLITS", ).text; - const closePaneShortcut = useHotkeyDisplay("CLOSE_TERMINAL").text; + const closePaneShortcut = useHotkeyDisplay("CLOSE_PANE").text; return useMemo[]>( () => [ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index dd6f76635e9..2a394444915 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -1,10 +1,9 @@ import type { RendererContext } from "@superset/panes"; +import { alert } from "@superset/ui/atoms/Alert"; import { useCallback, useEffect } from "react"; import { useSharedFileDocument } from "../../../../state/fileDocumentStore"; import type { FilePaneData, PaneViewerData } from "../../../../types"; -import { ConflictDialog } from "./components/ConflictDialog"; import { ErrorState } from "./components/ErrorState"; -import { ExternalChangeBar } from "./components/ExternalChangeBar"; import { LoadingState } from "./components/LoadingState"; import { OrphanedBanner } from "./components/OrphanedBanner"; import { SaveErrorBanner } from "./components/SaveErrorBanner"; @@ -24,15 +23,49 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { absolutePath: filePath, }); - // Mirror document dirty state back into the pane data so the tab indicator stays in sync. + // Follow the underlying file if it's renamed on disk β€” the store migrates + // the entry, document.absolutePath returns the new path, and we reconcile + // the pane's own filePath so the tab title updates. useEffect(() => { - if (document.dirty !== data.hasChanges) { + if (document.absolutePath !== data.filePath) { context.actions.updateData({ ...data, - hasChanges: document.dirty, + filePath: document.absolutePath, } as PaneViewerData); } - }, [document.dirty, data, context.actions]); + }, [document.absolutePath, data, context.actions]); + + useEffect(() => { + if (document.dirty && !context.pane.pinned) { + context.actions.pin(); + } + }, [document.dirty, context.pane.pinned, context.actions]); + + const hasConflict = document.conflict !== null; + useEffect(() => { + if (!hasConflict) return; + const name = filePath.split("/").pop(); + alert({ + title: `Do you want to save the changes you made to ${name}?`, + description: "Your changes will be lost if you don't save them.", + actions: [ + { + label: "Save", + onClick: () => document.resolveConflict("overwrite"), + }, + { + label: "Don't Save", + variant: "secondary", + onClick: () => document.resolveConflict("reload"), + }, + { + label: "Cancel", + variant: "ghost", + onClick: () => document.resolveConflict("keep"), + }, + ], + }); + }, [hasConflict, document, filePath]); const handleChangeView = useCallback( (viewId: string) => { @@ -78,8 +111,6 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { } const ViewRenderer = activeView.Renderer; - const localContent = - document.content.kind === "text" ? document.content.value : ""; return (
@@ -89,9 +120,6 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { onDiscard={() => void document.reload()} /> )} - {document.hasExternalChange && !document.conflict && ( - void document.reload()} /> - )} {document.saveError && (
- {document.conflict && ( - void document.resolveConflict("keep")} - onReload={() => void document.resolveConflict("reload")} - onOverwrite={() => void document.resolveConflict("overwrite")} - /> - )}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/ConflictDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/ConflictDialog.tsx deleted file mode 100644 index 1aa2bdb144a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/ConflictDialog.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { MultiFileDiff } from "@pierre/diffs/react"; -import { Button } from "@superset/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@superset/ui/dialog"; -import { useResolvedTheme } from "renderer/stores/theme"; - -interface ConflictDialogProps { - open: boolean; - filePath: string; - localContent: string; - diskContent: string | null; - pendingSave: boolean; - onKeepEditing: () => void; - onReload: () => void; - onOverwrite: () => void; -} - -export function ConflictDialog({ - open, - filePath, - localContent, - diskContent, - pendingSave, - onKeepEditing, - onReload, - onOverwrite, -}: ConflictDialogProps) { - const resolvedTheme = useResolvedTheme(); - const displayDiskContent = diskContent ?? ""; - - return ( - - -
- - File Changed On Disk - - {diskContent === null - ? `${filePath} was removed or is no longer readable. Review the difference before choosing whether to overwrite it.` - : `${filePath} changed on disk after you started editing. Review the diff before saving.`} - - -
- -
- - - - - -
-
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/index.ts deleted file mode 100644 index fac4d338b6a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ConflictDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ConflictDialog } from "./ConflictDialog"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx deleted file mode 100644 index f5bec2672f0..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx +++ /dev/null @@ -1,18 +0,0 @@ -interface ExternalChangeBarProps { - onReload: () => Promise | void; -} - -export function ExternalChangeBar({ onReload }: ExternalChangeBarProps) { - return ( -
- File changed on disk. - -
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/index.ts deleted file mode 100644 index fac487bd149..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ExternalChangeBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ExternalChangeBar } from "./ExternalChangeBar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx index d4f5305ebcc..1ae7283e6fc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx @@ -9,7 +9,7 @@ export function CodeView({ document, filePath }: ViewProps) { return ( document.setContent(next)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index c4f88ef0bcf..8ba2051c5cd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -28,7 +28,10 @@ import { useHotkeyDisplay } from "renderer/hotkeys"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import { useSettings } from "renderer/stores/settings"; -import { getDocument } from "../../state/fileDocumentStore"; +import { + getDocument, + useSharedFileDocument, +} from "../../state/fileDocumentStore"; import type { BrowserPaneData, ChatPaneData, @@ -54,6 +57,31 @@ function getFileName(filePath: string): string { return filePath.split("/").pop() ?? filePath; } +function FilePaneTabTitle({ + filePath, + pinned, + workspaceId, +}: { + filePath: string; + pinned: boolean; + workspaceId: string; +}) { + const document = useSharedFileDocument({ + workspaceId, + absolutePath: filePath, + }); + const name = getFileName(filePath); + return ( +
+ + {name} + {document.dirty && ( + + )} +
+ ); +} + const MOD_KEY = navigator.platform.toLowerCase().includes("mac") ? "⌘" : "Ctrl+"; @@ -129,17 +157,12 @@ export function usePaneRegistry( getTitle: (pane) => getFileName((pane.data as FilePaneData).filePath), renderTitle: (ctx: RendererContext) => { const data = ctx.pane.data as FilePaneData; - const name = getFileName(data.filePath); return ( -
- - - {name} - - {data.hasChanges && ( - - )} -
+ ); }, renderPane: (ctx: RendererContext) => ( @@ -152,7 +175,8 @@ export function usePaneRegistry( ctx.actions.pin(), onBeforeClose: (pane) => { const data = pane.data as FilePaneData; - if (!data.hasChanges) return true; + const doc = getDocument(workspaceId, data.filePath); + if (!doc?.dirty) return true; const name = data.filePath.split("/").pop(); return new Promise((resolve) => { alert({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index f8fb8103799..9d0c8e262be 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -1,6 +1,7 @@ import { type FocusDirection, getSpatialNeighborPaneId, + type PaneRegistry, type WorkspaceStore, } from "@superset/panes"; import { useCallback } from "react"; @@ -20,11 +21,13 @@ export function useWorkspaceHotkeys({ workspaceId, matchedPresets, executePreset, + paneRegistry, }: { store: StoreApi>; workspaceId: string; matchedPresets: V2TerminalPresetRow[]; executePreset: (preset: V2TerminalPresetRow) => void; + paneRegistry: PaneRegistry; }) { const collections = useCollections(); @@ -69,12 +72,16 @@ export function useWorkspaceHotkeys({ // --- Tab management --- - useHotkey("CLOSE_TERMINAL", () => { + useHotkey("CLOSE_PANE", async () => { const state = store.getState(); const active = state.getActivePane(); - if (active) { - state.closePane({ tabId: active.tabId, paneId: active.pane.id }); + if (!active) return; + const definition = paneRegistry[active.pane.kind]; + if (definition?.onBeforeClose) { + const allowed = await definition.onBeforeClose(active.pane); + if (!allowed) return; } + state.closePane({ tabId: active.tabId, paneId: active.pane.id }); }); useHotkey("CLOSE_TAB", () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 81b7d886144..d2affef0ddb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -33,7 +33,10 @@ import { useRecentlyViewedFiles } from "./hooks/useRecentlyViewedFiles"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; -import { FileDocumentStoreProvider } from "./state/fileDocumentStore"; +import { + FileDocumentStoreProvider, + getDocument, +} from "./state/fileDocumentStore"; import type { BrowserPaneData, ChatPaneData, @@ -149,7 +152,6 @@ function WorkspaceContent({ data: { filePath, mode: "editor", - hasChanges: false, } as FilePaneData, }, ], @@ -170,7 +172,6 @@ function WorkspaceContent({ data: { filePath, mode: "editor", - hasChanges: false, } as FilePaneData, }, }); @@ -304,7 +305,7 @@ function WorkspaceContent({ { key: "close", icon: , - tooltip: , + tooltip: , onClick: (ctx) => ctx.actions.close(), }, ], @@ -313,7 +314,13 @@ function WorkspaceContent({ const sidebarOpen = localWorkspaceState?.rightSidebarOpen ?? false; - useWorkspaceHotkeys({ store, workspaceId, matchedPresets, executePreset }); + useWorkspaceHotkeys({ + store, + workspaceId, + matchedPresets, + executePreset, + paneRegistry, + }); useHotkey("QUICK_OPEN", handleQuickOpen); return ( @@ -352,10 +359,11 @@ function WorkspaceContent({ )} onBeforeCloseTab={(tab) => { const dirtyFiles = Object.values(tab.panes) - .filter( - (p) => - p.kind === "file" && (p.data as FilePaneData).hasChanges, - ) + .filter((p) => { + if (p.kind !== "file") return false; + const filePath = (p.data as FilePaneData).filePath; + return getDocument(workspaceId, filePath)?.dirty === true; + }) .map((p) => (p.data as FilePaneData).filePath.split("/").pop(), ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx index 538a7f9d946..7476fb01fff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx @@ -1,11 +1,6 @@ -import { useWorkspaceClient } from "@superset/workspace-client"; -import { type ReactNode, useEffect } from "react"; +import type { ReactNode } from "react"; import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; -import { - dispatchFsEvent, - initializeFileDocumentStore, - teardownFileDocumentStore, -} from "./fileDocumentStore"; +import { dispatchFsEvent } from "./fileDocumentStore"; interface FileDocumentStoreProviderProps { workspaceId: string; @@ -16,15 +11,6 @@ export function FileDocumentStoreProvider({ workspaceId, children, }: FileDocumentStoreProviderProps) { - const { trpcClient } = useWorkspaceClient(); - - useEffect(() => { - initializeFileDocumentStore({ trpcClient }); - return () => { - teardownFileDocumentStore(); - }; - }, [trpcClient]); - useWorkspaceEvent("fs:events", workspaceId, (event) => { dispatchFsEvent(workspaceId, event); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts index 4f72f3a4251..2985297a147 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts @@ -11,8 +11,10 @@ import type { type WorkspaceTrpcClient = ReturnType; interface DocumentEntry { + id: string; workspaceId: string; absolutePath: string; + trpcClient: WorkspaceTrpcClient; content: ContentState; savedContentText: string | null; pendingSave: boolean; @@ -30,7 +32,6 @@ interface DocumentEntry { const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; const BINARY_CHECK_SIZE = 8192; -let activeTrpcClient: WorkspaceTrpcClient | null = null; const entries = new Map(); function key(workspaceId: string, absolutePath: string): string { @@ -76,28 +77,8 @@ function toBytes(value: string | Uint8Array): Uint8Array { return typeof value === "string" ? decodeBase64(value) : value; } -function requireClient(): WorkspaceTrpcClient { - if (!activeTrpcClient) { - throw new Error( - "fileDocumentStore accessed before initialization; ensure FileDocumentStoreProvider is mounted", - ); - } - return activeTrpcClient; -} - -export function initializeFileDocumentStore(config: { - trpcClient: WorkspaceTrpcClient; -}): void { - activeTrpcClient = config.trpcClient; -} - -export function teardownFileDocumentStore(): void { - activeTrpcClient = null; - entries.clear(); -} - async function loadEntry(entry: DocumentEntry): Promise { - const client = requireClient(); + const client = entry.trpcClient; try { const result = await client.filesystem.readFile.query({ workspaceId: entry.workspaceId, @@ -154,7 +135,7 @@ async function fetchCurrentDiskContent( entry: DocumentEntry, ): Promise { if (entry.isBinary) return null; - const client = requireClient(); + const client = entry.trpcClient; try { const result = await client.filesystem.readFile.query({ workspaceId: entry.workspaceId, @@ -172,6 +153,9 @@ async function fetchCurrentDiskContent( function createHandle(entry: DocumentEntry): SharedFileDocument { return { + get id() { + return entry.id; + }, get workspaceId() { return entry.workspaceId; }, @@ -218,7 +202,7 @@ function createHandle(entry: DocumentEntry): SharedFileDocument { error: new Error("Cannot save non-text content"), }; } - const client = requireClient(); + const client = entry.trpcClient; const currentValue = entry.content.value; const currentRevision = entry.content.revision; entry.pendingSave = true; @@ -312,13 +296,16 @@ function createHandle(entry: DocumentEntry): SharedFileDocument { export function acquireDocument( workspaceId: string, absolutePath: string, + trpcClient: WorkspaceTrpcClient, ): SharedFileDocument { const k = key(workspaceId, absolutePath); let entry = entries.get(k); if (!entry) { entry = { + id: crypto.randomUUID(), workspaceId, absolutePath, + trpcClient, content: { kind: "loading" }, savedContentText: null, pendingSave: false, @@ -376,45 +363,47 @@ export function dispatchFsEvent( workspaceId: string, event: FsWatchEvent, ): void { - for (const entry of entries.values()) { + // Snapshot before iterating β€” the rename branch below does entries.delete + + // entries.set on the same map, and JS Map iterators visit keys inserted + // mid-iteration, which would revisit the same entry and loop forever. + for (const entry of Array.from(entries.values())) { if (entry.workspaceId !== workspaceId) continue; const affects = entry.absolutePath === event.absolutePath || (event.kind === "rename" && event.oldAbsolutePath === entry.absolutePath); if (!affects) continue; - switch (event.kind) { - case "delete": { - entry.orphaned = true; - notify(entry); - break; - } - case "rename": { - // Migrate the entry to its new absolute path - const oldKey = key(entry.workspaceId, entry.absolutePath); - entries.delete(oldKey); - entry.absolutePath = event.absolutePath; - entries.set(key(entry.workspaceId, entry.absolutePath), entry); - if (computeDirty(entry)) { - entry.hasExternalChange = true; - } + const isContentMutation = + event.kind === "create" || + event.kind === "update" || + event.kind === "overflow" || + (event.kind === "rename" && event.absolutePath === entry.absolutePath); + + if (event.kind === "delete") { + entry.orphaned = true; + notify(entry); + continue; + } + + if ( + event.kind === "rename" && + event.oldAbsolutePath === entry.absolutePath + ) { + const oldKey = key(entry.workspaceId, entry.absolutePath); + entries.delete(oldKey); + entry.absolutePath = event.absolutePath; + entries.set(key(entry.workspaceId, entry.absolutePath), entry); + notify(entry); + continue; + } + + if (isContentMutation) { + if (entry.orphaned) entry.orphaned = false; + if (computeDirty(entry)) { + entry.hasExternalChange = true; notify(entry); - break; - } - case "create": - case "update": - case "overflow": { - // Clear orphan if the file reappeared - if (entry.orphaned) { - entry.orphaned = false; - } - if (computeDirty(entry)) { - entry.hasExternalChange = true; - notify(entry); - } else { - void loadEntry(entry); - } - break; + } else { + void loadEntry(entry); } } } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts index 779acd06cf7..b21cfba5c96 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts @@ -3,9 +3,7 @@ export { acquireDocument, dispatchFsEvent, getDocument, - initializeFileDocumentStore, releaseDocument, - teardownFileDocumentStore, } from "./fileDocumentStore"; export type { ConflictResolution, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts index 866500af245..7ee499aa55f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts @@ -20,6 +20,7 @@ export interface ConflictState { } export interface SharedFileDocument { + readonly id: string; readonly workspaceId: string; readonly absolutePath: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts index f4d9839f670..2d6ac4e9233 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts @@ -1,3 +1,4 @@ +import { useWorkspaceClient } from "@superset/workspace-client"; import { useEffect, useState, useSyncExternalStore } from "react"; import { acquireDocument, releaseDocument } from "./fileDocumentStore"; import type { SharedFileDocument } from "./types"; @@ -11,9 +12,39 @@ export function useSharedFileDocument({ workspaceId, absolutePath, }: UseSharedFileDocumentParams): SharedFileDocument { - const [handle] = useState(() => - acquireDocument(workspaceId, absolutePath), - ); + const { trpcClient } = useWorkspaceClient(); + + const [state, setState] = useState<{ + handle: SharedFileDocument; + workspaceId: string; + absolutePath: string; + }>(() => ({ + handle: acquireDocument(workspaceId, absolutePath, trpcClient), + workspaceId, + absolutePath, + })); + + // Swap handles synchronously when the pane is retargeted at a different + // file (e.g. a preview pane reassigned from env.ts to bun.lock). setState + // during render restarts the render before commit so consumers never + // observe a handle pointing at the previous file. + if ( + state.workspaceId !== workspaceId || + state.absolutePath !== absolutePath + ) { + // Rename case: the entry behind our existing handle was migrated to + // match the new props. Reuse the handle β€” acquiring again would bump + // refCount a second time and release() of the old key no-ops (the + // entry isn't at that key anymore), which would leak one lease per + // rename. + const handleAlreadyPointsAtNewPath = + state.handle.workspaceId === workspaceId && + state.handle.absolutePath === absolutePath; + const handle = handleAlreadyPointsAtNewPath + ? state.handle + : acquireDocument(workspaceId, absolutePath, trpcClient); + setState({ handle, workspaceId, absolutePath }); + } useEffect(() => { return () => { @@ -21,7 +52,11 @@ export function useSharedFileDocument({ }; }, [workspaceId, absolutePath]); - useSyncExternalStore(handle.subscribe, handle.getVersion, handle.getVersion); + useSyncExternalStore( + state.handle.subscribe, + state.handle.getVersion, + state.handle.getVersion, + ); - return handle; + return state.handle; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts index 29ba2c76171..0615d9c65f7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts @@ -1,7 +1,6 @@ export interface FilePaneData { filePath: string; mode: "editor" | "diff" | "preview"; - hasChanges: boolean; language?: string; viewId?: string; forceViewId?: string; diff --git a/apps/desktop/src/shared/themes/editor-theme.ts b/apps/desktop/src/shared/themes/editor-theme.ts index d2b88c58579..1994d695692 100644 --- a/apps/desktop/src/shared/themes/editor-theme.ts +++ b/apps/desktop/src/shared/themes/editor-theme.ts @@ -15,10 +15,7 @@ export function getEditorTheme(theme: Theme): EditorTheme { cursor: terminal?.cursor ?? theme.ui.foreground, gutterBackground: theme.ui.background, gutterForeground: theme.ui.mutedForeground, - activeLine: withAlpha( - theme.ui.foreground, - theme.type === "dark" ? 0.04 : 0.06, - ), + activeLine: withAlpha(theme.ui.accent, 0.5), selection: terminal?.selectionBackground ?? withAlpha(theme.ui.primary, theme.type === "dark" ? 0.28 : 0.18), From 6e07fec129c1d8a1e375eb4556efe885334016da Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 23:27:10 -0700 Subject: [PATCH 13/16] fix(desktop): v2 "Don't Save" now discards the dirty buffer Dirty document entries never dispose (refCount <= 0 && !dirty rule), so choosing "Don't Save" on close left the entry floating in the store. Reopening the same file reattached to that stale dirty buffer, which looked like dirty state bleeding between files. Both close prompts now reload the buffer to disk before resolving, and the multi-file tab close's "Save All" actually saves each dirty pane via doc.save() instead of the previous TODO no-op. --- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 6 ++- .../v2-workspace/$workspaceId/page.tsx | 49 +++++++++++++------ .../fileDocumentStore/fileDocumentStore.ts | 2 - 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 8ba2051c5cd..e394fd3f5d6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -201,7 +201,11 @@ export function usePaneRegistry( { label: "Don't Save", variant: "secondary", - onClick: () => resolve(true), + onClick: async () => { + const doc = getDocument(workspaceId, data.filePath); + if (doc) await doc.reload(); + resolve(true); + }, }, { label: "Cancel", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index d2affef0ddb..98d7daa704f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -358,20 +358,19 @@ function WorkspaceContent({ /> )} onBeforeCloseTab={(tab) => { - const dirtyFiles = Object.values(tab.panes) - .filter((p) => { - if (p.kind !== "file") return false; - const filePath = (p.data as FilePaneData).filePath; - return getDocument(workspaceId, filePath)?.dirty === true; - }) - .map((p) => - (p.data as FilePaneData).filePath.split("/").pop(), - ); - if (dirtyFiles.length === 0) return true; + const dirtyPanes = Object.values(tab.panes).filter((p) => { + if (p.kind !== "file") return false; + const filePath = (p.data as FilePaneData).filePath; + return getDocument(workspaceId, filePath)?.dirty === true; + }); + const dirtyFileNames = dirtyPanes.map((p) => + (p.data as FilePaneData).filePath.split("/").pop(), + ); + if (dirtyPanes.length === 0) return true; const title = - dirtyFiles.length === 1 - ? `Do you want to save the changes you made to ${dirtyFiles[0]}?` - : `Do you want to save changes to ${dirtyFiles.length} files?`; + dirtyPanes.length === 1 + ? `Do you want to save the changes you made to ${dirtyFileNames[0]}?` + : `Do you want to save changes to ${dirtyPanes.length} files?`; return new Promise((resolve) => { alert({ title, @@ -380,15 +379,33 @@ function WorkspaceContent({ actions: [ { label: "Save All", - onClick: () => { - // TODO: wire up save via editor refs + onClick: async () => { + for (const pane of dirtyPanes) { + const filePath = (pane.data as FilePaneData) + .filePath; + const doc = getDocument(workspaceId, filePath); + if (!doc) continue; + const result = await doc.save(); + if (result.status !== "saved") { + resolve(false); + return; + } + } resolve(true); }, }, { label: "Don't Save", variant: "secondary", - onClick: () => resolve(true), + onClick: async () => { + for (const pane of dirtyPanes) { + const filePath = (pane.data as FilePaneData) + .filePath; + const doc = getDocument(workspaceId, filePath); + if (doc) await doc.reload(); + } + resolve(true); + }, }, { label: "Cancel", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts index 2985297a147..8c8a24136c0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts @@ -334,8 +334,6 @@ export function releaseDocument( const entry = entries.get(k); if (!entry) return; entry.refCount -= 1; - // Block disposal when the buffer has unsaved edits or the file was deleted externally β€” - // matches VS Code's TextFileEditorModelManager.canDispose rule. if (entry.refCount <= 0 && !computeDirty(entry) && !entry.orphaned) { entries.delete(k); } From 8d5923831ac3d6a2636cbe0eec6e1118ee364d27 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 23:45:36 -0700 Subject: [PATCH 14/16] feat(desktop): v2 image + binary + large-file handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Images load via binary encoding (readAsBinary = isImageFile) so PNGs and friends don't go through a corrupting utf-8 decode path. - ImageView renders on a 1:1 checkerboard underlay (10% foreground mix) so transparency reads correctly against either theme. - Non-image binaries stay as utf-8 text so "Open Anyway" can actually render them in the code view. The isBinary flag is still used for view resolution. - binaryWarningView demoted from exclusive β†’ default priority, and codeView no longer matches binary files, so an image's view toggle shows [Image] only rather than [Binary, Image], and unknown binaries default to the BinaryWarning with code as a toggle fallback. - Default load cap bumped 2 MB β†’ 10 MB. too-large now offers an "Open anyway" button via a new document.loadUnlimited() method. --- .../components/FilePane/FilePane.tsx | 7 ++- .../components/ErrorState/ErrorState.tsx | 12 ++++- .../registry/views/BinaryWarningView/index.ts | 2 +- .../FilePane/registry/views/CodeView/index.ts | 2 +- .../registry/views/ImageView/ImageView.tsx | 21 +++++--- .../fileDocumentStore/fileDocumentStore.ts | 51 ++++++++++--------- .../state/fileDocumentStore/types.ts | 1 + 7 files changed, 61 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index 2a394444915..0055b09164d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -97,7 +97,12 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { return ; } if (document.content.kind === "too-large") { - return ; + return ( + void document.loadUnlimited()} + /> + ); } if (document.content.kind === "is-directory") { return ; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx index 347727baa48..2c066f8ec73 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx @@ -1,3 +1,5 @@ +import { Button } from "@superset/ui/button"; + export type ErrorReason = | "not-found" | "too-large" @@ -6,6 +8,7 @@ export type ErrorReason = interface ErrorStateProps { reason: ErrorReason; + onOpenAnyway?: () => void; } const MESSAGES: Record = { @@ -15,10 +18,15 @@ const MESSAGES: Record = { "binary-unsupported": "Binary file β€” cannot display", }; -export function ErrorState({ reason }: ErrorStateProps) { +export function ErrorState({ reason, onOpenAnyway }: ErrorStateProps) { return ( -
+
{MESSAGES[reason]} + {reason === "too-large" && onOpenAnyway && ( + + )}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts index 9d57bbcd4cb..73a7a5fb923 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts @@ -5,7 +5,7 @@ export const binaryWarningView: FileView = { id: "binary-warning", label: "Binary", match: (_, meta) => meta.isBinary === true, - priority: "exclusive", + priority: "default", documentKind: "bytes", Renderer: BinaryWarningView, }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts index aba0ef30690..b61e0ae6970 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts @@ -5,7 +5,7 @@ import { CodeView } from "./CodeView"; export const codeView: FileView = { id: "code", label: (filePath) => (isMarkdownFile(filePath) ? "Markdown" : "Code"), - match: () => true, + match: (_, meta) => meta.isBinary !== true, priority: "builtin", documentKind: "text", Renderer: CodeView, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx index f52e4c0057a..ac9cff2725b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx @@ -20,12 +20,21 @@ export function ImageView({ document, filePath }: ViewProps) { return (
- {filePath.split("/").pop() +
+ {filePath.split("/").pop() +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts index 8c8a24136c0..709cb1c3e7a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts @@ -1,5 +1,6 @@ import type { workspaceTrpc } from "@superset/workspace-client"; import type { FsWatchEvent } from "@superset/workspace-fs/client"; +import { isImageFile } from "shared/file-types"; import type { ConflictResolution, ConflictState, @@ -29,7 +30,7 @@ interface DocumentEntry { subscribers: Set<() => void>; } -const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; const BINARY_CHECK_SIZE = 8192; const entries = new Map(); @@ -77,14 +78,19 @@ function toBytes(value: string | Uint8Array): Uint8Array { return typeof value === "string" ? decodeBase64(value) : value; } -async function loadEntry(entry: DocumentEntry): Promise { +async function loadEntry( + entry: DocumentEntry, + options: { unlimited?: boolean } = {}, +): Promise { const client = entry.trpcClient; + const readAsBinary = isImageFile(entry.absolutePath); + const maxBytes = options.unlimited ? undefined : DEFAULT_MAX_BYTES; try { const result = await client.filesystem.readFile.query({ workspaceId: entry.workspaceId, absolutePath: entry.absolutePath, - encoding: "utf-8", - maxBytes: DEFAULT_MAX_BYTES, + encoding: readAsBinary ? undefined : "utf-8", + maxBytes, }); entry.byteSize = result.byteLength; @@ -97,33 +103,24 @@ async function loadEntry(entry: DocumentEntry): Promise { return; } - if (result.kind === "text") { - entry.isBinary = isBinaryText(result.content); - if (entry.isBinary) { - entry.content = { - kind: "bytes", - value: new Uint8Array(), - revision: result.revision, - }; - } else { - entry.content = { - kind: "text", - value: result.content, - revision: result.revision, - }; - entry.savedContentText = result.content; - } + if (result.kind === "bytes") { + entry.isBinary = true; + entry.content = { + kind: "bytes", + value: toBytes(result.content), + revision: result.revision, + }; notify(entry); return; } - // Raw bytes from host β€” e.g., image files - entry.isBinary = true; + entry.isBinary = isBinaryText(result.content); entry.content = { - kind: "bytes", - value: toBytes(result.content), + kind: "text", + value: result.content, revision: result.revision, }; + entry.savedContentText = result.content; notify(entry); } catch { entry.content = { kind: "not-found" }; @@ -260,6 +257,12 @@ function createHandle(entry: DocumentEntry): SharedFileDocument { notify(entry); await loadEntry(entry); }, + async loadUnlimited() { + entry.content = { kind: "loading" }; + entry.savedContentText = null; + notify(entry); + await loadEntry(entry, { unlimited: true }); + }, async resolveConflict(choice: ConflictResolution) { if (!entry.conflict) return; if (choice === "reload") { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts index 7ee499aa55f..e86b769c14a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts @@ -39,6 +39,7 @@ export interface SharedFileDocument { setContent(next: string): void; save(opts?: { force?: boolean }): Promise; reload(): Promise; + loadUnlimited(): Promise; resolveConflict(choice: ConflictResolution): Promise; clearSaveError(): void; From b7cd725c222212efe2ccf2e00f1f1fc16986acb8 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 23:59:20 -0700 Subject: [PATCH 15/16] feat(desktop): make v2 markdown preview read-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches VS Code's model β€” preview is a view of the source, not an editor. Avoids TipTap's markdown round-trip drift (parse β†’ ProseMirror β†’ serialize) that would spuriously mark files dirty and reformat on save. Edits continue to happen in the code view; preview re-renders from the shared document. --- .../views/MarkdownPreviewView/MarkdownPreviewView.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx index 54ba8761fbd..f808d146768 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx @@ -10,12 +10,6 @@ export function MarkdownPreviewView({ document }: ViewProps) {
{ - if (typeof next === "string") { - document.setContent(next); - } - }} onSave={() => void document.save()} />
From 227571cf17ef94187b84a4fdc377cb36a90976ce Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 09:30:18 -0700 Subject: [PATCH 16/16] fix(desktop): address PR feedback from coderabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - save(): preserve live buffer when keystrokes arrive during an in-flight write β€” only bump revision/savedContentText on success. - loadEntry: distinguish ENOENT (not-found) from transient/permission errors; new { kind: "error" } content state + load-failed ErrorState with a Retry button. - resetForLoad helper shared by reload() and loadUnlimited() so both clear conflict/hasExternalChange/saveError consistently. - useCopyToClipboard: navigator.clipboard primary + offscreen-textarea + execCommand fallback (matches VS Code's browserClipboardService); preserves caller's selection range. - PathActionsMenuItems uses toast.promise for copy feedback. - CLOSE_PANE hotkey guarded with isClosingPaneRef against re-entrant prompts when Cmd+W is pressed rapidly. - ImageView switches from btoa(base64) data URLs to URL.createObjectURL(Blob) with cleanup; ~3Γ— lower peak memory. - CodeEditorAdapter cut/paste async callbacks no-op after dispose. - MarkdownPreviewView drops the now-dead onSave prop. - Active-line and selection backgrounds share a theme.ui.accent @ 0.5 alpha so the selection is actually visible against the theme. - setup/steps.sh: Electric container now uses --restart on-failure:5 so a container stuck on a Neon session orphan doesn't loop indefinitely without the setup-script cleanup path running again. --- .superset/lib/setup/steps.sh | 2 +- .../src/renderer/hooks/useCopyToClipboard.ts | 42 ++++++++++++++++- .../PathActionsMenuItems.tsx | 15 +++--- .../components/FilePane/FilePane.tsx | 9 ++++ .../components/ErrorState/ErrorState.tsx | 20 ++++++-- .../CodeEditorAdapter/CodeEditorAdapter.ts | 2 + .../createCodeMirrorTheme.ts | 15 ++---- .../registry/views/ImageView/ImageView.tsx | 24 ++++++---- .../MarkdownPreviewView.tsx | 5 +- .../useWorkspaceHotkeys.ts | 25 ++++++---- .../fileDocumentStore/fileDocumentStore.ts | 46 +++++++++++++------ .../state/fileDocumentStore/types.ts | 3 +- 12 files changed, 146 insertions(+), 62 deletions(-) diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index d16724e9505..2043b023c95 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -259,7 +259,7 @@ step_start_electric() { if ! docker run -d \ --name "$ELECTRIC_CONTAINER" \ - --restart unless-stopped \ + --restart on-failure:5 \ $port_flag \ -e DATABASE_URL="$DIRECT_URL" \ -e ELECTRIC_SECRET="$ELECTRIC_SECRET" \ diff --git a/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts b/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts index b9766364ec1..86a5ff8263c 100644 --- a/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts +++ b/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts @@ -1,11 +1,51 @@ import { useCallback, useState } from "react"; +async function writeTextToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return; + } catch {} + + const textarea = window.document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.width = "1px"; + textarea.style.height = "1px"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + + const body = window.document.body; + body.appendChild(textarea); + + const previousSelection = window.document.getSelection(); + const previousRange = + previousSelection && previousSelection.rangeCount > 0 + ? previousSelection.getRangeAt(0) + : null; + + try { + textarea.select(); + textarea.setSelectionRange(0, text.length); + const ok = window.document.execCommand("copy"); + if (!ok) throw new Error("Copy to clipboard failed"); + } finally { + body.removeChild(textarea); + if (previousRange && previousSelection) { + previousSelection.removeAllRanges(); + previousSelection.addRange(previousRange); + } + } +} + export function useCopyToClipboard(timeout = 2000) { const [copied, setCopied] = useState(false); const copyToClipboard = useCallback( async (text: string) => { - await navigator.clipboard.writeText(text); + await writeTextToClipboard(text); setCopied(true); setTimeout(() => setCopied(false), timeout); }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx index c1bf48057ba..9b5d66bf614 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx @@ -17,15 +17,12 @@ export function PathActionsMenuItems({ }: PathActionsMenuItemsProps) { const { copyToClipboard } = useCopyToClipboard(); - const handleCopy = async (path: string, successMessage: string) => { - try { - await copyToClipboard(path); - toast.success(successMessage); - } catch (error) { - toast.error( - `Failed to copy path: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - } + const handleCopy = (path: string, successMessage: string) => { + toast.promise(copyToClipboard(path), { + success: successMessage, + error: (err: unknown) => + `Failed to copy path: ${err instanceof Error ? err.message : "Unknown error"}`, + }); }; const handleRevealInFinder = async () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index 0055b09164d..cb5f682b458 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -107,6 +107,15 @@ export function FilePane({ context, workspaceId }: FilePaneProps) { if (document.content.kind === "is-directory") { return ; } + if (document.content.kind === "error") { + return ( + void document.reload()} + /> + ); + } // The same resolution runs in FilePaneHeaderExtras β€” toggle + active view // stay in lockstep because both observe the same pane data + document. diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx index 2c066f8ec73..624e3585aca 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx @@ -4,11 +4,14 @@ export type ErrorReason = | "not-found" | "too-large" | "is-directory" - | "binary-unsupported"; + | "binary-unsupported" + | "load-failed"; interface ErrorStateProps { reason: ErrorReason; + message?: string; onOpenAnyway?: () => void; + onRetry?: () => void; } const MESSAGES: Record = { @@ -16,17 +19,28 @@ const MESSAGES: Record = { "too-large": "File is too large to preview", "is-directory": "This path is a directory", "binary-unsupported": "Binary file β€” cannot display", + "load-failed": "Failed to load file", }; -export function ErrorState({ reason, onOpenAnyway }: ErrorStateProps) { +export function ErrorState({ + reason, + message, + onOpenAnyway, + onRetry, +}: ErrorStateProps) { return (
- {MESSAGES[reason]} + {message ?? MESSAGES[reason]} {reason === "too-large" && onOpenAnyway && ( )} + {reason === "load-failed" && onRetry && ( + + )}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/CodeEditorAdapter.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/CodeEditorAdapter.ts index 86467104412..ee6c9d5d235 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/CodeEditorAdapter.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/CodeEditorAdapter.ts @@ -74,6 +74,7 @@ export function createCodeMirrorAdapter(view: EditorView): CodeEditorAdapter { void clipboard .writeText(text) .then(() => { + if (disposed) return; const currentSelection = view.state.selection.main; if ( currentSelection.from !== selection.from || @@ -115,6 +116,7 @@ export function createCodeMirrorAdapter(view: EditorView): CodeEditorAdapter { void clipboard .readText() .then((text) => { + if (disposed) return; const selection = view.state.selection.main; view.dispatch({ changes: { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts index 311584a9ac6..eec9e8c7573 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts @@ -18,14 +18,9 @@ export function createCodeMirrorTheme( const fontSize = fontSettings.fontSize ?? DEFAULT_CODE_EDITOR_FONT_SIZE; const lineHeight = Math.round(fontSize * 1.5); const editorTheme = getEditorTheme(theme); - // Shared subtle overlay for both active-line highlight and selection so - // they read as the same visual weight β€” foreground at low alpha, derived - // from the app palette. - const lineHighlightBackground = withAlpha( - theme.ui.foreground, - theme.type === "dark" ? 0.025 : 0.04, - ); - const selectionBackground = lineHighlightBackground; + const accentOverlay = withAlpha(theme.ui.accent, 0.5); + const activeLineBackground = accentOverlay; + const selectionBackground = accentOverlay; return EditorView.theme( { @@ -112,10 +107,10 @@ export function createCodeMirrorTheme( cursor: "pointer", }, ".cm-activeLine": { - backgroundColor: lineHighlightBackground, + backgroundColor: activeLineBackground, }, ".cm-activeLineGutter": { - backgroundColor: lineHighlightBackground, + backgroundColor: activeLineBackground, }, // Suppress the active-line highlight while a selection is active β€” // the selectionClassTogglePlugin adds .cm-hasSelection to the editor diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx index ac9cff2725b..dae1d9a845e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx @@ -1,20 +1,24 @@ -import { useMemo } from "react"; +import { useEffect, useState } from "react"; import { getImageMimeType } from "shared/file-types"; import type { ViewProps } from "../../types"; export function ImageView({ document, filePath }: ViewProps) { - const dataUrl = useMemo(() => { - if (document.content.kind !== "bytes") return null; + const [objectUrl, setObjectUrl] = useState(null); + + useEffect(() => { + if (document.content.kind !== "bytes") { + setObjectUrl(null); + return; + } const mimeType = getImageMimeType(filePath) ?? "image/png"; - const base64 = btoa( - Array.from(document.content.value) - .map((b) => String.fromCharCode(b)) - .join(""), + const url = URL.createObjectURL( + new Blob([document.content.value as BlobPart], { type: mimeType }), ); - return `data:${mimeType};base64,${base64}`; + setObjectUrl(url); + return () => URL.revokeObjectURL(url); }, [document.content, filePath]); - if (!dataUrl) { + if (!objectUrl) { return null; } @@ -29,7 +33,7 @@ export function ImageView({ document, filePath }: ViewProps) { }} > {filePath.split("/").pop() - void document.save()} - /> +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index 9d0c8e262be..83308f52a08 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -4,7 +4,7 @@ import { type PaneRegistry, type WorkspaceStore, } from "@superset/panes"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { useHotkey } from "renderer/hotkeys"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; @@ -72,16 +72,23 @@ export function useWorkspaceHotkeys({ // --- Tab management --- + const isClosingPaneRef = useRef(false); useHotkey("CLOSE_PANE", async () => { - const state = store.getState(); - const active = state.getActivePane(); - if (!active) return; - const definition = paneRegistry[active.pane.kind]; - if (definition?.onBeforeClose) { - const allowed = await definition.onBeforeClose(active.pane); - if (!allowed) return; + if (isClosingPaneRef.current) return; + isClosingPaneRef.current = true; + try { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + const definition = paneRegistry[active.pane.kind]; + if (definition?.onBeforeClose) { + const allowed = await definition.onBeforeClose(active.pane); + if (!allowed) return; + } + state.closePane({ tabId: active.tabId, paneId: active.pane.id }); + } finally { + isClosingPaneRef.current = false; } - state.closePane({ tabId: active.tabId, paneId: active.pane.id }); }); useHotkey("CLOSE_TAB", () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts index 709cb1c3e7a..7e1120d7c70 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts @@ -52,6 +52,14 @@ function computeDirty(entry: DocumentEntry): boolean { return entry.content.value !== entry.savedContentText; } +function resetForLoad(entry: DocumentEntry): void { + entry.content = { kind: "loading" }; + entry.savedContentText = null; + entry.conflict = null; + entry.hasExternalChange = false; + entry.saveError = null; +} + function isBinaryText(content: string): boolean { const checkLength = Math.min(content.length, BINARY_CHECK_SIZE); for (let i = 0; i < checkLength; i += 1) { @@ -122,12 +130,26 @@ async function loadEntry( }; entry.savedContentText = result.content; notify(entry); - } catch { - entry.content = { kind: "not-found" }; + } catch (error) { + const isNotFound = isEnoentLikeError(error); + entry.content = isNotFound + ? { kind: "not-found" } + : { kind: "error", error: error as Error }; notify(entry); } } +function isEnoentLikeError(error: unknown): boolean { + if (!error) return false; + const message = + error instanceof Error ? error.message.toLowerCase() : String(error); + return ( + message.includes("enoent") || + message.includes("no such file") || + message.includes("not found") + ); +} + async function fetchCurrentDiskContent( entry: DocumentEntry, ): Promise { @@ -231,11 +253,12 @@ function createHandle(entry: DocumentEntry): SharedFileDocument { return { status: result.reason }; } - entry.content = { - kind: "text", - value: currentValue, - revision: result.revision, - }; + if (entry.content.kind === "text") { + entry.content = { + ...entry.content, + revision: result.revision, + }; + } entry.savedContentText = currentValue; entry.conflict = null; entry.hasExternalChange = false; @@ -249,17 +272,12 @@ function createHandle(entry: DocumentEntry): SharedFileDocument { } }, async reload() { - entry.content = { kind: "loading" }; - entry.savedContentText = null; - entry.conflict = null; - entry.hasExternalChange = false; - entry.saveError = null; + resetForLoad(entry); notify(entry); await loadEntry(entry); }, async loadUnlimited() { - entry.content = { kind: "loading" }; - entry.savedContentText = null; + resetForLoad(entry); notify(entry); await loadEntry(entry, { unlimited: true }); }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts index e86b769c14a..30dd8523459 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts @@ -4,7 +4,8 @@ export type ContentState = | { kind: "bytes"; value: Uint8Array; revision: string } | { kind: "not-found" } | { kind: "too-large" } - | { kind: "is-directory" }; + | { kind: "is-directory" } + | { kind: "error"; error: Error }; export type SaveResult = | { status: "saved"; revision: string }