From e4f7ca86f77f258afc76d86fa38d1602a082b775 Mon Sep 17 00:00:00 2001 From: Jokin Date: Sat, 11 Jul 2026 20:28:54 +0200 Subject: [PATCH] Edit file: built-in editor + external editor with watch & auto-upload - Built-in editor (default): large modal with monospace textarea, Cmd+S save, dirty guard; reads/writes through the provider streams so it works on local and remote files identically (2MB cap with a friendly error) - External editor: remote files download to appDataDir/edits/, open via macOS `open -a`, the temp DIR is watched (survives atomic rename-saves) and every save enqueues a normal upload job (progress/errors in the transfer list); watchers + temp dirs are cleaned when the session disconnects - Editor setting: Built-in | Custom with a NATIVE app picker (electrobun openFileDialog, /Applications, .app filter) and reset-to-default - Leftover temp files from a crash prompt for cleanup at boot - F4, toolbar button (mynaFileText), and File > Edit File (Cmd+E) menu entry - README: feature notes + Portability notes (open/start/xdg-open seam documented in code and README per user request) Verified headless against the box: sftp read/write roundtrip incl. UTF-8, size cap, watch->save->auto-upload->remote-updated, session cleanup, orphan listing and clearing. Co-Authored-By: Claude Fable 5 --- README.md | 18 +++ src/bun/edits.ts | 187 ++++++++++++++++++++++++++ src/bun/index.ts | 4 +- src/bun/menu.ts | 2 + src/bun/rpc-handlers.ts | 45 ++++++- src/shared/rpc.ts | 22 +++ src/shared/types.ts | 6 + ui/src/app/app.html | 3 + ui/src/app/app.ts | 21 +++ ui/src/app/dialogs.service.ts | 11 ++ ui/src/app/dialogs/editor-dialog.ts | 158 ++++++++++++++++++++++ ui/src/app/file-actions.service.ts | 38 ++++++ ui/src/app/file-panel/file-panel.html | 7 + ui/src/app/file-panel/file-panel.ts | 7 +- ui/src/app/icons.ts | 2 + ui/src/app/settings.service.ts | 16 +++ ui/src/app/tab-bar/tab-bar.ts | 64 +++++++++ 17 files changed, 606 insertions(+), 5 deletions(-) create mode 100644 src/bun/edits.ts create mode 100644 ui/src/app/dialogs/editor-dialog.ts diff --git a/README.md b/README.md index dce387b..21698fb 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ remote server over SSH/SFTP, so you can move files **local↔remote**, (MB/s or Mbps), persisted across restarts. - On macOS the tab bar is fused with the window titlebar; other platforms keep a standard titlebar. +- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local + and remote files directly; or pick an external app (Options → Editor → + Custom → Choose…). External edits of remote files download to a temp folder, + are watched, and every save auto-uploads (visible in the transfer list) until + the session disconnects. Leftover temp files from a crash are offered for + cleanup at the next start. - Browse, sort (name/size/modified, folders first), keyboard navigation. - Create folder / file, rename, delete (recursive), change permissions (chmod). - Upload, download, and remote→remote copy — files **and** whole folders — @@ -136,6 +142,18 @@ Saved connections live in a plain JSON file in the OS app-data directory (On Macs, function keys may need `fn` — every action also has a toolbar button.) +## Portability notes + +The codebase is platform-neutral except for two seams, both macOS-only today: + +- **External editor launch** (`launchEditor` in [src/bun/edits.ts](src/bun/edits.ts)): + uses macOS `open [-a app] file`. A Windows port should use + `cmd /c start "" [app] file`; Linux `xdg-open file` (or invoke the configured + editor binary directly). The `.app` picker in the `pickEditorApp` handler + needs the equivalent executable picker per platform. +- **Application menu** ([src/bun/menu.ts](src/bun/menu.ts)): electrobun menus + are macOS-only; the in-app UI covers every menu action. + ## Known issues - **Rare native crash under load.** A long-running session (multiple remote diff --git a/src/bun/edits.ts b/src/bun/edits.ts new file mode 100644 index 0000000..39dfd3c --- /dev/null +++ b/src/bun/edits.ts @@ -0,0 +1,187 @@ +import { watch, type FSWatcher } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import type { FileSystemProvider } from "./fs/provider"; +import type { SessionRegistry } from "./fs/registry"; +import type { SettingsStore } from "./store/settings"; +import { appDataDir } from "./store/paths"; +import type { TransferEngine } from "./transfers/engine"; + +// Built-in editor guard: bigger than this is not a config file. +const MAX_EDIT_BYTES = 2 * 1024 * 1024; +const SAVE_DEBOUNCE_MS = 400; + +/** Read a whole file as UTF-8 through any provider (local or sftp). */ +export async function readFileText( + provider: FileSystemProvider, + filePath: string, +): Promise { + const stat = await provider.stat(filePath); + if (stat.size > MAX_EDIT_BYTES) { + throw new Error( + `File is ${Math.round(stat.size / 1024 / 1024)}MB — too large for the built-in editor. Use a custom editor instead.`, + ); + } + const chunks: Buffer[] = []; + const stream = provider.createReadStream(filePath); + for await (const chunk of stream) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +/** Write UTF-8 content through any provider. */ +export function writeFileText( + provider: FileSystemProvider, + filePath: string, + content: string, +): Promise { + return new Promise((resolve, reject) => { + const stream = provider.createWriteStream(filePath); + stream.on("error", reject); + stream.end(Buffer.from(content, "utf8"), () => resolve()); + }); +} + +/** + * Launch a file in an external editor. + * + * PORTABILITY: this is the only platform-specific piece of the edit feature. + * macOS delivers the file to any GUI app uniformly via `open [-a app]`. + * A Windows port should use `cmd /c start "" [app] file` and a Linux port + * `xdg-open file` (or `app file` when an editor is configured) — swap the + * spawn below and the .app picker in pickEditorApp; everything else + * (temp download, directory watch, auto-upload) is platform-neutral. + */ +function launchEditor(filePath: string, editorApp: string): void { + const args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath]; + Bun.spawn(args, { stdout: "ignore", stderr: "ignore" }); +} + +interface WatchedEdit { + sessionId: string; + remotePath: string; + tempDir: string; + tempFile: string; + watcher: FSWatcher; + debounce: ReturnType | null; +} + +/** + * External-editor flow for remote files: download to a temp dir, open the + * editor, watch the directory (survives atomic rename-saves), and enqueue an + * upload job on every save. Watchers die with their session; temp dirs left + * behind by a crash are offered for cleanup at next boot. + */ +export class EditManager { + private readonly editsDir = path.join(appDataDir(), "edits"); + private watched: WatchedEdit[] = []; + + constructor( + private registry: SessionRegistry, + private engine: TransferEngine, + private settings: SettingsStore, + ) {} + + async editExternal(sessionId: string, filePath: string): Promise<{ watching: boolean }> { + const { editorApp } = await this.settings.load(); + + if (sessionId === "local") { + launchEditor(filePath, editorApp); + return { watching: false }; + } + + const provider = this.registry.get(sessionId); + const tempDir = path.join(this.editsDir, crypto.randomUUID().slice(0, 8)); + const name = provider.basename(filePath); + const tempFile = path.join(tempDir, name); + + await fs.mkdir(tempDir, { recursive: true }); + await fs.writeFile( + path.join(tempDir, "meta.json"), + JSON.stringify({ remotePath: filePath, sessionId, downloadedAt: new Date().toISOString() }), + ); + const { createWriteStream } = await import("node:fs"); + await pipeline(provider.createReadStream(filePath), createWriteStream(tempFile)); + + launchEditor(tempFile, editorApp); + + const edit: WatchedEdit = { + sessionId, + remotePath: filePath, + tempDir, + tempFile, + watcher: watch(tempDir, (_event, changed) => { + if (changed !== name) return; + if (edit.debounce) clearTimeout(edit.debounce); + edit.debounce = setTimeout(() => this.uploadEdit(edit), SAVE_DEBOUNCE_MS); + }), + debounce: null, + }; + this.watched.push(edit); + console.log(`[edit] watching ${tempFile} -> ${sessionId}:${filePath}`); + return { watching: true }; + } + + private uploadEdit(edit: WatchedEdit): void { + const provider = this.registry.get(edit.sessionId); + this.engine.enqueue({ + srcSessionId: "local", + items: [{ path: edit.tempFile, type: "file" }], + dstSessionId: edit.sessionId, + dstDir: provider.dirname(edit.remotePath), + }); + } + + /** Session gone (disconnect, tab close, death): stop its watchers, drop temps. */ + stopForSession(sessionId: string): void { + const [mine, rest] = [ + this.watched.filter((w) => w.sessionId === sessionId), + this.watched.filter((w) => w.sessionId !== sessionId), + ]; + this.watched = rest; + for (const edit of mine) { + edit.watcher.close(); + if (edit.debounce) clearTimeout(edit.debounce); + void fs.rm(edit.tempDir, { recursive: true, force: true }); + } + } + + /** Temp files from previous runs (crash / quit mid-edit). */ + async listLeftovers(): Promise { + const names: string[] = []; + let entries: string[]; + try { + entries = await fs.readdir(this.editsDir); + } catch { + return names; + } + const active = new Set(this.watched.map((w) => path.basename(w.tempDir))); + for (const entry of entries) { + if (active.has(entry)) continue; + try { + const files = await fs.readdir(path.join(this.editsDir, entry)); + const file = files.find((f) => f !== "meta.json"); + names.push(file ?? entry); + } catch { + names.push(entry); + } + } + return names; + } + + async clearLeftovers(): Promise { + const active = new Set(this.watched.map((w) => path.basename(w.tempDir))); + let entries: string[] = []; + try { + entries = await fs.readdir(this.editsDir); + } catch { + return; + } + for (const entry of entries) { + if (active.has(entry)) continue; + await fs.rm(path.join(this.editsDir, entry), { recursive: true, force: true }); + } + } +} diff --git a/src/bun/index.ts b/src/bun/index.ts index c3a6ae4..32fe4ea 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -1,6 +1,7 @@ import { ApplicationMenu, BrowserWindow, Updater } from "electrobun/bun"; import { BRANDING } from "../shared/branding"; import { effectiveConcurrency } from "../shared/types"; +import { EditManager } from "./edits"; import { SessionRegistry } from "./fs/registry"; import { applyApplicationMenu } from "./menu"; import { createAppRPC } from "./rpc-handlers"; @@ -32,11 +33,12 @@ const registry = new SessionRegistry(); const engine = new TransferEngine(registry); const store = new ConnectionStore(); const settings = new SettingsStore(); +const edits = new EditManager(registry, engine, settings); const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {}, connectionsChanged: () => {}, }; -const rpc = createAppRPC({ registry, engine, store, settings, events }); +const rpc = createAppRPC({ registry, engine, store, settings, edits, events }); const url = await getMainViewUrl(); const isMac = process.platform === "darwin"; diff --git a/src/bun/menu.ts b/src/bun/menu.ts index 3b0f541..31e2222 100644 --- a/src/bun/menu.ts +++ b/src/bun/menu.ts @@ -48,6 +48,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise void; @@ -32,7 +34,7 @@ async function wrap(fn: () => Promise | T): Promise({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { @@ -45,6 +47,7 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD if (!sessionId) return; registry.drop(sessionId); engine.failSession(sessionId, reason); + edits.stopForSession(sessionId); events.sessionClosed({ sessionId, reason }); }); sessionId = registry.add(provider); @@ -55,6 +58,7 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD disconnect: ({ sessionId }) => wrap(async () => { + edits.stopForSession(sessionId); await registry.remove(sessionId); console.log(`[rpc] disconnected ${sessionId}`); return {}; @@ -160,6 +164,43 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD engine.setConcurrency(effectiveConcurrency(next)); return {}; }), + + // ---- file editing ---- + readFile: ({ sessionId, path }) => + wrap(async () => ({ + content: await readFileText(registry.get(sessionId), path), + })), + + writeFile: ({ sessionId, path, content }) => + wrap(async () => { + await writeFileText(registry.get(sessionId), path, content); + return {}; + }), + + editExternal: ({ sessionId, path }) => + wrap(() => edits.editExternal(sessionId, path)), + + pickEditorApp: () => + wrap(async () => { + const picked = await Utils.openFileDialog({ + startingFolder: "/Applications", + allowedFileTypes: "app", + canChooseFiles: true, + canChooseDirectory: false, + allowsMultipleSelection: false, + }); + const path = picked.find((p) => p.trim().length > 0) ?? null; + return { path }; + }), + + getEditLeftovers: () => + wrap(async () => ({ names: await edits.listLeftovers() })), + + clearEditLeftovers: () => + wrap(async () => { + await edits.clearLeftovers(); + return {}; + }), }, }, }); diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 3eb3eb4..90c0211 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -74,6 +74,28 @@ export type AppRPC = { response: Result<{ settings: AppSettings }>; }; saveSettings: { params: { settings: AppSettings }; response: Result }; + // file editing + readFile: { + params: { sessionId: string; path: string }; + response: Result<{ content: string }>; + }; + writeFile: { + params: { sessionId: string; path: string; content: string }; + response: Result; + }; + editExternal: { + params: { sessionId: string; path: string }; + response: Result<{ watching: boolean }>; + }; + pickEditorApp: { + params: Record; + response: Result<{ path: string | null }>; + }; + getEditLeftovers: { + params: Record; + response: Result<{ names: string[] }>; + }; + clearEditLeftovers: { params: Record; response: Result }; }; messages: Record; }; diff --git a/src/shared/types.ts b/src/shared/types.ts index c43656b..a1e5325 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -52,6 +52,10 @@ export interface AppSettings { transferConcurrency: number; /** Remove completed transfers from the list automatically. */ autoClearCompleted: boolean; + /** Edit files with the built-in editor or an external application. */ + editor: "builtin" | "custom"; + /** Full path to the external editor .app; empty = OS default application. */ + editorApp: string; } export const DEFAULT_SETTINGS: AppSettings = { @@ -61,6 +65,8 @@ export const DEFAULT_SETTINGS: AppSettings = { transferMode: "single", transferConcurrency: 3, autoClearCompleted: true, + editor: "builtin", + editorApp: "", }; export function effectiveConcurrency(settings: AppSettings): number { diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 217d323..267146f 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -37,6 +37,9 @@ @if (dialogs.overwrite(); as req) { } + @if (dialogs.editor(); as req) { + + }
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 6e8429f..a45f3c1 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -5,6 +5,7 @@ import { ConnectionManager } from './connection-manager/connection-manager'; import { ConnectionsService } from './connections.service'; import { ChmodDialog } from './dialogs/chmod-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog'; +import { EditorDialog } from './dialogs/editor-dialog'; import { OverwriteDialog } from './dialogs/overwrite-dialog'; import { PromptDialog } from './dialogs/prompt-dialog'; import { DialogsService } from './dialogs.service'; @@ -28,6 +29,7 @@ import { TransferBar } from './transfer-bar/transfer-bar'; ConfirmDialog, ChmodDialog, OverwriteDialog, + EditorDialog, TransferBar, ConnectionManager, ], @@ -46,9 +48,25 @@ export class App { document.title = BRANDING.appName; if (this.rpc.available) { this.rpc.onMenuAction((action) => this.handleMenuAction(action)); + void this.promptForEditLeftovers(); } } + /** Temp files from external edits in a previous run (crash / quit mid-edit). */ + private async promptForEditLeftovers(): Promise { + const res = await this.rpc.request.getEditLeftovers({}); + if (!res.ok || !res.names.length) return; + const shown = res.names.slice(0, 5).join(', '); + const more = res.names.length > 5 ? `, +${res.names.length - 5} more` : ''; + this.dialogs.confirm.set({ + title: 'Leftover edited files', + body: `A previous session left ${res.names.length} temporary edited file(s) behind (${shown}${more}). Clear them?`, + confirmLabel: 'Clear', + destructive: true, + onConfirm: () => void this.rpc.request.clearEditLeftovers({}), + }); + } + /** Native application-menu actions forwarded from the bun process. */ private handleMenuAction(action: string): void { if (action.startsWith('connect:')) { @@ -72,6 +90,9 @@ export class App { case 'new-file': this.actions.createFile(this.panels.active()); break; + case 'edit-file': + void this.actions.edit(this.panels.active()); + break; case 'refresh-active': void this.panels.active().refresh(); break; diff --git a/ui/src/app/dialogs.service.ts b/ui/src/app/dialogs.service.ts index b0b91d6..cd783b0 100644 --- a/ui/src/app/dialogs.service.ts +++ b/ui/src/app/dialogs.service.ts @@ -1,5 +1,6 @@ import { Injectable, signal } from '@angular/core'; import type { FileEntry } from '@shared/types'; +import type { PanelState } from './panels.service'; export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel'; @@ -33,12 +34,22 @@ export interface ChmodRequest { onConfirm: (mode: number) => void; } +export interface EditorRequest { + sessionId: string; + path: string; + name: string; + sessionTitle: string; + content: string; + panel: PanelState; // refreshed after save +} + @Injectable({ providedIn: 'root' }) export class DialogsService { readonly prompt = signal(null); readonly confirm = signal(null); readonly chmod = signal(null); readonly overwrite = signal(null); + readonly editor = signal(null); readonly manager = signal(false); /** Ask what to do about one name collision; resolves with the user's choice. */ diff --git a/ui/src/app/dialogs/editor-dialog.ts b/ui/src/app/dialogs/editor-dialog.ts new file mode 100644 index 0000000..fe23002 --- /dev/null +++ b/ui/src/app/dialogs/editor-dialog.ts @@ -0,0 +1,158 @@ +import { + Component, + ElementRef, + afterNextRender, + inject, + input, + signal, + viewChild, +} from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { DialogsService, type EditorRequest } from '../dialogs.service'; +import { PanelsService } from '../panels.service'; +import { RpcService } from '../rpc.service'; +import { ToastService } from '../toast.service'; + +@Component({ + selector: 'app-editor-dialog', + imports: [NgIcon], + template: ` +
+
+
+ +

+ {{ request().name }}{{ dirty() ? ' •' : '' }} +

+ + {{ request().sessionTitle }} + +
+ +
+ + + +
+ ⌘S saves · Esc closes +
+ + +
+
+
+ `, +}) +export class EditorDialog { + readonly request = input.required(); + + readonly text = signal(''); + readonly dirty = signal(false); + readonly saving = signal(false); + + private readonly box = viewChild.required>('box'); + private readonly dialogs = inject(DialogsService); + private readonly panels = inject(PanelsService); + private readonly rpc = inject(RpcService); + private readonly toast = inject(ToastService); + + constructor() { + afterNextRender(() => { + this.text.set(this.request().content); + this.box().nativeElement.focus(); + }); + } + + onInput(value: string): void { + this.text.set(value); + this.dirty.set(value !== this.request().content); + } + + onKeydown(event: KeyboardEvent): void { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + void this.save(); + } else if (event.key === 'Escape') { + event.preventDefault(); + this.close(); + } else if (event.key === 'Tab' && event.target === this.box().nativeElement) { + // keep Tab as an input character inside the editor + event.preventDefault(); + const el = this.box().nativeElement; + const { selectionStart, selectionEnd, value } = el; + el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`; + el.selectionStart = el.selectionEnd = selectionStart + 1; + this.onInput(el.value); + } + } + + async save(): Promise { + const req = this.request(); + if (!this.dirty() || this.saving()) return; + this.saving.set(true); + try { + const res = await this.rpc.request.writeFile({ + sessionId: req.sessionId, + path: req.path, + content: this.text(), + }); + if (!res.ok) { + this.toast.error(res.error); + return; + } + this.toast.success(`Saved ${req.name}`); + req.content = this.text(); + this.dirty.set(false); + void req.panel.refresh(); + } finally { + this.saving.set(false); + } + } + + close(): void { + if (this.dirty()) { + this.dialogs.confirm.set({ + title: 'Discard changes?', + body: `"${this.request().name}" has unsaved changes.`, + confirmLabel: 'Discard', + destructive: true, + onConfirm: () => this.forceClose(), + }); + return; + } + this.forceClose(); + } + + private forceClose(): void { + this.dialogs.editor.set(null); + this.panels.refocus(); + } +} diff --git a/ui/src/app/file-actions.service.ts b/ui/src/app/file-actions.service.ts index ced8d05..ee248e9 100644 --- a/ui/src/app/file-actions.service.ts +++ b/ui/src/app/file-actions.service.ts @@ -4,6 +4,7 @@ import { DialogsService } from './dialogs.service'; import { joinPath } from './format'; import { type PanelState } from './panels.service'; import { RpcService } from './rpc.service'; +import { SettingsService } from './settings.service'; import { ToastService } from './toast.service'; /** @@ -15,6 +16,7 @@ export class FileActionsService { private readonly rpc = inject(RpcService); private readonly toast = inject(ToastService); private readonly dialogs = inject(DialogsService); + private readonly settings = inject(SettingsService); /** Selection if non-empty, else the cursor entry. Never the ".." parent row. */ targets(panel: PanelState): FileEntry[] { @@ -56,6 +58,42 @@ export class FileActionsService { }); } + /** Open the cursor file in the built-in editor or the configured external app. */ + async edit(panel: PanelState): Promise { + const entry = panel.sorted()[panel.cursor()]; + if (!entry || entry.type !== 'file' || entry.name === '..') return; + + if (this.settings.editor() === 'custom') { + const res = await this.rpc.request.editExternal({ + sessionId: panel.sessionId(), + path: entry.path, + }); + if (!res.ok) { + this.toast.error(res.error); + } else if (res.watching) { + this.toast.info(`Editing ${entry.name} — saves upload automatically`); + } + return; + } + + const res = await this.rpc.request.readFile({ + sessionId: panel.sessionId(), + path: entry.path, + }); + if (!res.ok) { + this.toast.error(res.error); + return; + } + this.dialogs.editor.set({ + sessionId: panel.sessionId(), + path: entry.path, + name: entry.name, + sessionTitle: panel.title(), + content: res.content, + panel, + }); + } + rename(panel: PanelState): void { const entry = panel.sorted()[panel.cursor()]; if (!entry || entry.name === '..') return; diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html index c8b7375..c831812 100644 --- a/ui/src/app/file-panel/file-panel.html +++ b/ui/src/app/file-panel/file-panel.html @@ -37,6 +37,13 @@ > +
} +
+ Editor +
+ + +
+
+ + @if (settings.editor() === 'custom') { +
+ + {{ editorAppName() }} + +
+ + @if (settings.editorApp()) { + + } +
+
+ } +
Finished transfers
@@ -219,6 +269,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; export class TabBar { readonly panels = inject(PanelsService); readonly settings = inject(SettingsService); + private readonly rpc = inject(RpcService); readonly settingsOpen = signal(false); readonly isMacChrome = IS_MAC_CHROME; readonly themes = THEMES; @@ -248,6 +299,19 @@ export class TabBar { this.panels.closeWorkspace(id); } + /** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */ + editorAppName(): string { + const path = this.settings.editorApp(); + if (!path) return 'System default'; + const base = path.split('/').filter(Boolean).pop() ?? path; + return base.replace(/\.app$/i, ''); + } + + async pickEditorApp(): Promise { + const res = await this.rpc.request.pickEditorApp({}); + if (res.ok && res.path) this.settings.setEditorApp(res.path); + } + /** Insertion line at the drop position (inset shadow: no layout shift). */ dropIndicatorShadow(index: number): string | null { const target = this.dropTarget();