diff --git a/apps/desktop/package.json b/apps/desktop/package.json index df1cb7bfff4..f494ce9a246 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -77,6 +77,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/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 | β€” | 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..624e3585aca --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx @@ -0,0 +1,46 @@ +import { Button } from "@superset/ui/button"; + +export type ErrorReason = + | "not-found" + | "too-large" + | "is-directory" + | "binary-unsupported" + | "load-failed"; + +interface ErrorStateProps { + reason: ErrorReason; + message?: string; + onOpenAnyway?: () => void; + onRetry?: () => void; +} + +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", + "load-failed": "Failed to load file", +}; + +export function ErrorState({ + reason, + message, + onOpenAnyway, + onRetry, +}: ErrorStateProps) { + return ( +
+ {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/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/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx new file mode 100644 index 00000000000..0958cc7e829 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx @@ -0,0 +1,48 @@ +import type { RendererContext } from "@superset/panes"; +import { useCallback } from "react"; +import { useSharedFileDocument } from "../../../../../../state/fileDocumentStore"; +import type { FilePaneData, PaneViewerData } from "../../../../../../types"; +import { orderForToggle, resolveActivePaneView } from "../../registry"; +import { FileViewToggle } from "../FileViewToggle"; + +interface FilePaneHeaderExtrasProps { + context: RendererContext; + 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], + ); + + const { views, activeView } = resolveActivePaneView(document, data); + + if (views.length <= 1 || data.forceViewId) return null; + 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/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/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/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/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..ed0e9f4e8df --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts @@ -0,0 +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. +// 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 new file mode 100644 index 00000000000..e67fc0a79bb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts @@ -0,0 +1,20 @@ +export { ALL_VIEWS } from "./allViews"; +export { + orderForToggle, + pickDefaultView, + resolveViews, +} from "./resolveViews"; +export { + type DocumentKind, + type FileMeta, + type FileView, + type FileViewLabel, + PRIORITY_RANK, + type Priority, + 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/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..f37a67cf92d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts @@ -0,0 +1,23 @@ +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; +} + +// 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 new file mode 100644 index 00000000000..9b1dfdb664c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts @@ -0,0 +1,43 @@ +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 type FileViewLabel = string | ((filePath: string) => string); + +export interface FileView { + id: string; + label: FileViewLabel; + match: (filePath: string, meta: FileMeta) => boolean; + priority: Priority; + documentKind: DocumentKind; + Renderer: ComponentType; +} + +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/utils/resolveActivePaneView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/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/utils/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/utils/resolveActivePaneView/resolveActivePaneView.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/resolveActivePaneView.ts new file mode 100644 index 00000000000..35ab6840f01 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/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/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..73a7a5fb923 --- /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: "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/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..1ae7283e6fc --- /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..e47e58fa9c8 --- /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,271 @@ +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from "@codemirror/commands"; +import { + bracketMatching, + codeFolding, + foldGutter, + foldKeymap, + 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 { 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"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useResolvedTheme } from "renderer/stores/theme"; +import { + type CodeEditorAdapter, + 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"; + +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(), + foldGutter({ markerDOM: buildFoldChevron }), + codeFolding({ placeholderDOM: buildFoldPlaceholder }), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + highlightActiveLine(), + highlightSelectionMatches(), + colorPicker, + contourSelectionLayer, + selectionClassTogglePlugin, + editableCompartment.of([ + EditorState.readOnly.of(readOnly), + EditorView.editable.of(!readOnly), + ]), + EditorView.contentAttributes.of({ + spellcheck: "false", + }), + keymap.of([ + indentWithTab, + ...defaultKeymap, + ...historyKeymap, + ...searchKeymap, + ...foldKeymap, + ]), + 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/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 new file mode 100644 index 00000000000..ee6c9d5d235 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/CodeEditorAdapter.ts @@ -0,0 +1,143 @@ +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(() => { + if (disposed) return; + 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) => { + if (disposed) return; + 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/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/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/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 new file mode 100644 index 00000000000..eec9e8c7573 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts @@ -0,0 +1,171 @@ +import { EditorView } from "@codemirror/view"; +import { getEditorTheme, type Theme, withAlpha } 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); + const accentOverlay = withAlpha(theme.ui.accent, 0.5); + const activeLineBackground = accentOverlay; + const selectionBackground = accentOverlay; + + 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, + border: "none", + }, + // 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: activeLineBackground, + }, + ".cm-activeLineGutter": { + backgroundColor: activeLineBackground, + }, + // 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-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/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/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/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/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 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/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/loadLanguageSupport/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 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/loadLanguageSupport/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/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/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 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/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..b61e0ae6970 --- /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,12 @@ +import { isMarkdownFile } from "shared/file-types"; +import type { FileView } from "../../types"; +import { CodeView } from "./CodeView"; + +export const codeView: FileView = { + id: "code", + label: (filePath) => (isMarkdownFile(filePath) ? "Markdown" : "Code"), + 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 new file mode 100644 index 00000000000..dae1d9a845e --- /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,44 @@ +import { useEffect, useState } from "react"; +import { getImageMimeType } from "shared/file-types"; +import type { ViewProps } from "../../types"; + +export function ImageView({ document, filePath }: ViewProps) { + const [objectUrl, setObjectUrl] = useState(null); + + useEffect(() => { + if (document.content.kind !== "bytes") { + setObjectUrl(null); + return; + } + const mimeType = getImageMimeType(filePath) ?? "image/png"; + const url = URL.createObjectURL( + new Blob([document.content.value as BlobPart], { type: mimeType }), + ); + setObjectUrl(url); + return () => URL.revokeObjectURL(url); + }, [document.content, filePath]); + + if (!objectUrl) { + 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..f6f0da93d73 --- /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,14 @@ +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 ( +
+ +
+ ); +} 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/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..7476fb01fff --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; +import { dispatchFsEvent } from "./fileDocumentStore"; + +interface FileDocumentStoreProviderProps { + workspaceId: string; + children: ReactNode; +} + +export function FileDocumentStoreProvider({ + workspaceId, + children, +}: FileDocumentStoreProviderProps) { + useWorkspaceEvent("fs:events", workspaceId, (event) => { + 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 new file mode 100644 index 00000000000..7e1120d7c70 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts @@ -0,0 +1,429 @@ +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, + ContentState, + SaveResult, + SharedFileDocument, +} from "./types"; + +type WorkspaceTrpcClient = ReturnType; + +interface DocumentEntry { + id: string; + workspaceId: string; + absolutePath: string; + trpcClient: WorkspaceTrpcClient; + 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 = 10 * 1024 * 1024; +const BINARY_CHECK_SIZE = 8192; + +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 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) { + 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; +} + +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: readAsBinary ? undefined : "utf-8", + maxBytes, + }); + + entry.byteSize = result.byteLength; + entry.orphaned = false; + entry.hasExternalChange = false; + + if (result.exceededLimit) { + entry.content = { kind: "too-large" }; + notify(entry); + return; + } + + if (result.kind === "bytes") { + entry.isBinary = true; + entry.content = { + kind: "bytes", + value: toBytes(result.content), + revision: result.revision, + }; + notify(entry); + return; + } + + entry.isBinary = isBinaryText(result.content); + entry.content = { + kind: "text", + value: result.content, + revision: result.revision, + }; + entry.savedContentText = result.content; + notify(entry); + } 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 { + if (entry.isBinary) return null; + const client = entry.trpcClient; + 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 id() { + return entry.id; + }, + get workspaceId() { + return entry.workspaceId; + }, + get absolutePath() { + return entry.absolutePath; + }, + get content() { + return entry.content; + }, + 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; + 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 = entry.trpcClient; + 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, + absolutePath: entry.absolutePath, + content: currentValue, + encoding: "utf-8", + precondition: + opts?.force || !currentRevision + ? undefined + : { ifMatch: currentRevision }, + }); + + entry.pendingSave = false; + + if (!result.ok) { + if (result.reason === "conflict") { + const diskContent = await fetchCurrentDiskContent(entry); + entry.conflict = { diskContent }; + entry.hasExternalChange = true; + notify(entry); + return { status: "conflict", diskContent }; + } + notify(entry); + return { status: result.reason }; + } + + if (entry.content.kind === "text") { + entry.content = { + ...entry.content, + 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() { + resetForLoad(entry); + notify(entry); + await loadEntry(entry); + }, + async loadUnlimited() { + resetForLoad(entry); + notify(entry); + await loadEntry(entry, { unlimited: true }); + }, + 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 () => { + entry.subscribers.delete(listener); + }; + }, + getVersion() { + return entry.version; + }, + }; +} + +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, + saveError: null, + conflict: null, + orphaned: false, + hasExternalChange: false, + isBinary: null, + byteSize: 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) && !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); +} + +/** + * 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 { + // 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; + + 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); + } 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 new file mode 100644 index 00000000000..b21cfba5c96 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts @@ -0,0 +1,15 @@ +export { FileDocumentStoreProvider } from "./FileDocumentStoreProvider"; +export { + acquireDocument, + dispatchFsEvent, + getDocument, + releaseDocument, +} from "./fileDocumentStore"; +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 new file mode 100644 index 00000000000..30dd8523459 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts @@ -0,0 +1,49 @@ +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" } + | { kind: "error"; error: Error }; + +export type SaveResult = + | { status: "saved"; revision: string } + | { 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 id: string; + readonly workspaceId: string; + readonly absolutePath: string; + + 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; + loadUnlimited(): Promise; + resolveConflict(choice: ConflictResolution): Promise; + clearSaveError(): void; + + 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..2d6ac4e9233 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts @@ -0,0 +1,62 @@ +import { useWorkspaceClient } from "@superset/workspace-client"; +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 { 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 () => { + releaseDocument(workspaceId, absolutePath); + }; + }, [workspaceId, absolutePath]); + + useSyncExternalStore( + state.handle.subscribe, + state.handle.getVersion, + state.handle.getVersion, + ); + + 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 da939a78a3c..cdd76061abc 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 @@ -4,6 +4,10 @@ export interface FilePaneData { hasChanges: boolean; displayName?: string; language?: string; + /** Added in PR4 (c504 foundation). Wired by PR5 adaptation. */ + viewId?: string; + /** Added in PR4 (c504 foundation). Wired by PR5 adaptation. */ + forceViewId?: string; } export interface TerminalPaneData { 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 139c43d30f7..6196eb261dc 100644 --- a/bun.lock +++ b/bun.lock @@ -154,6 +154,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:*", @@ -2283,6 +2284,8 @@ "@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.6", "", {}, "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA=="], + "@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=="], 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, };