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/<id>, 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 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,12 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
|
|||||||
(MB/s or Mbps), persisted across restarts.
|
(MB/s or Mbps), persisted across restarts.
|
||||||
- On macOS the tab bar is fused with the window titlebar; other platforms keep
|
- On macOS the tab bar is fused with the window titlebar; other platforms keep
|
||||||
a standard titlebar.
|
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.
|
- Browse, sort (name/size/modified, folders first), keyboard navigation.
|
||||||
- Create folder / file, rename, delete (recursive), change permissions (chmod).
|
- Create folder / file, rename, delete (recursive), change permissions (chmod).
|
||||||
- Upload, download, and remote→remote copy — files **and** whole folders —
|
- 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.)
|
(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
|
## Known issues
|
||||||
|
|
||||||
- **Rare native crash under load.** A long-running session (multiple remote
|
- **Rare native crash under load.** A long-running session (multiple remote
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<typeof setTimeout> | 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<string[]> {
|
||||||
|
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<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -1,6 +1,7 @@
|
|||||||
import { ApplicationMenu, BrowserWindow, Updater } from "electrobun/bun";
|
import { ApplicationMenu, BrowserWindow, Updater } from "electrobun/bun";
|
||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
import { effectiveConcurrency } from "../shared/types";
|
import { effectiveConcurrency } from "../shared/types";
|
||||||
|
import { EditManager } from "./edits";
|
||||||
import { SessionRegistry } from "./fs/registry";
|
import { SessionRegistry } from "./fs/registry";
|
||||||
import { applyApplicationMenu } from "./menu";
|
import { applyApplicationMenu } from "./menu";
|
||||||
import { createAppRPC } from "./rpc-handlers";
|
import { createAppRPC } from "./rpc-handlers";
|
||||||
@@ -32,11 +33,12 @@ const registry = new SessionRegistry();
|
|||||||
const engine = new TransferEngine(registry);
|
const engine = new TransferEngine(registry);
|
||||||
const store = new ConnectionStore();
|
const store = new ConnectionStore();
|
||||||
const settings = new SettingsStore();
|
const settings = new SettingsStore();
|
||||||
|
const edits = new EditManager(registry, engine, settings);
|
||||||
const events = {
|
const events = {
|
||||||
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
||||||
connectionsChanged: () => {},
|
connectionsChanged: () => {},
|
||||||
};
|
};
|
||||||
const rpc = createAppRPC({ registry, engine, store, settings, events });
|
const rpc = createAppRPC({ registry, engine, store, settings, edits, events });
|
||||||
const url = await getMainViewUrl();
|
const url = await getMainViewUrl();
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
|||||||
{ label: "New Folder", action: "new-folder" },
|
{ label: "New Folder", action: "new-folder" },
|
||||||
{ label: "New File", action: "new-file" },
|
{ label: "New File", action: "new-file" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
|
{ label: "Edit File", action: "edit-file", accelerator: "e" },
|
||||||
|
{ type: "separator" },
|
||||||
{ label: "Connect", submenu: connectSubmenu },
|
{ label: "Connect", submenu: connectSubmenu },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+43
-2
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserView } from "electrobun/bun";
|
import { BrowserView, Utils } from "electrobun/bun";
|
||||||
import type { AppRPC } from "../shared/rpc";
|
import type { AppRPC } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type Result } from "../shared/types";
|
import { effectiveConcurrency, type Result } from "../shared/types";
|
||||||
|
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||||
import type { SessionRegistry } from "./fs/registry";
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
import { SftpProvider } from "./fs/sftp";
|
import { SftpProvider } from "./fs/sftp";
|
||||||
import type { ConnectionStore } from "./store/connections";
|
import type { ConnectionStore } from "./store/connections";
|
||||||
@@ -17,6 +18,7 @@ export interface AppDeps {
|
|||||||
engine: TransferEngine;
|
engine: TransferEngine;
|
||||||
store: ConnectionStore;
|
store: ConnectionStore;
|
||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
|
edits: EditManager;
|
||||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||||
events: {
|
events: {
|
||||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
||||||
@@ -32,7 +34,7 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAppRPC({ registry, engine, store, settings, events }: AppDeps) {
|
export function createAppRPC({ registry, engine, store, settings, edits, events }: AppDeps) {
|
||||||
return BrowserView.defineRPC<AppRPC>({
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
maxRequestTime: MAX_REQUEST_TIME_MS,
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
handlers: {
|
handlers: {
|
||||||
@@ -45,6 +47,7 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD
|
|||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
registry.drop(sessionId);
|
registry.drop(sessionId);
|
||||||
engine.failSession(sessionId, reason);
|
engine.failSession(sessionId, reason);
|
||||||
|
edits.stopForSession(sessionId);
|
||||||
events.sessionClosed({ sessionId, reason });
|
events.sessionClosed({ sessionId, reason });
|
||||||
});
|
});
|
||||||
sessionId = registry.add(provider);
|
sessionId = registry.add(provider);
|
||||||
@@ -55,6 +58,7 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD
|
|||||||
|
|
||||||
disconnect: ({ sessionId }) =>
|
disconnect: ({ sessionId }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
|
edits.stopForSession(sessionId);
|
||||||
await registry.remove(sessionId);
|
await registry.remove(sessionId);
|
||||||
console.log(`[rpc] disconnected ${sessionId}`);
|
console.log(`[rpc] disconnected ${sessionId}`);
|
||||||
return {};
|
return {};
|
||||||
@@ -160,6 +164,43 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD
|
|||||||
engine.setConcurrency(effectiveConcurrency(next));
|
engine.setConcurrency(effectiveConcurrency(next));
|
||||||
return {};
|
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 {};
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,28 @@ export type AppRPC = {
|
|||||||
response: Result<{ settings: AppSettings }>;
|
response: Result<{ settings: AppSettings }>;
|
||||||
};
|
};
|
||||||
saveSettings: { params: { settings: AppSettings }; response: Result };
|
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<string, never>;
|
||||||
|
response: Result<{ path: string | null }>;
|
||||||
|
};
|
||||||
|
getEditLeftovers: {
|
||||||
|
params: Record<string, never>;
|
||||||
|
response: Result<{ names: string[] }>;
|
||||||
|
};
|
||||||
|
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
||||||
};
|
};
|
||||||
messages: Record<never, never>;
|
messages: Record<never, never>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export interface AppSettings {
|
|||||||
transferConcurrency: number;
|
transferConcurrency: number;
|
||||||
/** Remove completed transfers from the list automatically. */
|
/** Remove completed transfers from the list automatically. */
|
||||||
autoClearCompleted: boolean;
|
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 = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
@@ -61,6 +65,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
transferMode: "single",
|
transferMode: "single",
|
||||||
transferConcurrency: 3,
|
transferConcurrency: 3,
|
||||||
autoClearCompleted: true,
|
autoClearCompleted: true,
|
||||||
|
editor: "builtin",
|
||||||
|
editorApp: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function effectiveConcurrency(settings: AppSettings): number {
|
export function effectiveConcurrency(settings: AppSettings): number {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
@if (dialogs.overwrite(); as req) {
|
@if (dialogs.overwrite(); as req) {
|
||||||
<app-overwrite-dialog [request]="req" />
|
<app-overwrite-dialog [request]="req" />
|
||||||
}
|
}
|
||||||
|
@if (dialogs.editor(); as req) {
|
||||||
|
<app-editor-dialog [request]="req" />
|
||||||
|
}
|
||||||
|
|
||||||
<!-- toasts -->
|
<!-- toasts -->
|
||||||
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ConnectionManager } from './connection-manager/connection-manager';
|
|||||||
import { ConnectionsService } from './connections.service';
|
import { ConnectionsService } from './connections.service';
|
||||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||||
|
import { EditorDialog } from './dialogs/editor-dialog';
|
||||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
||||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
import { PromptDialog } from './dialogs/prompt-dialog';
|
||||||
import { DialogsService } from './dialogs.service';
|
import { DialogsService } from './dialogs.service';
|
||||||
@@ -28,6 +29,7 @@ import { TransferBar } from './transfer-bar/transfer-bar';
|
|||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
ChmodDialog,
|
ChmodDialog,
|
||||||
OverwriteDialog,
|
OverwriteDialog,
|
||||||
|
EditorDialog,
|
||||||
TransferBar,
|
TransferBar,
|
||||||
ConnectionManager,
|
ConnectionManager,
|
||||||
],
|
],
|
||||||
@@ -46,9 +48,25 @@ export class App {
|
|||||||
document.title = BRANDING.appName;
|
document.title = BRANDING.appName;
|
||||||
if (this.rpc.available) {
|
if (this.rpc.available) {
|
||||||
this.rpc.onMenuAction((action) => this.handleMenuAction(action));
|
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<void> {
|
||||||
|
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. */
|
/** Native application-menu actions forwarded from the bun process. */
|
||||||
private handleMenuAction(action: string): void {
|
private handleMenuAction(action: string): void {
|
||||||
if (action.startsWith('connect:')) {
|
if (action.startsWith('connect:')) {
|
||||||
@@ -72,6 +90,9 @@ export class App {
|
|||||||
case 'new-file':
|
case 'new-file':
|
||||||
this.actions.createFile(this.panels.active());
|
this.actions.createFile(this.panels.active());
|
||||||
break;
|
break;
|
||||||
|
case 'edit-file':
|
||||||
|
void this.actions.edit(this.panels.active());
|
||||||
|
break;
|
||||||
case 'refresh-active':
|
case 'refresh-active':
|
||||||
void this.panels.active().refresh();
|
void this.panels.active().refresh();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import type { FileEntry } from '@shared/types';
|
import type { FileEntry } from '@shared/types';
|
||||||
|
import type { PanelState } from './panels.service';
|
||||||
|
|
||||||
export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel';
|
export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel';
|
||||||
|
|
||||||
@@ -33,12 +34,22 @@ export interface ChmodRequest {
|
|||||||
onConfirm: (mode: number) => void;
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DialogsService {
|
export class DialogsService {
|
||||||
readonly prompt = signal<PromptRequest | null>(null);
|
readonly prompt = signal<PromptRequest | null>(null);
|
||||||
readonly confirm = signal<ConfirmRequest | null>(null);
|
readonly confirm = signal<ConfirmRequest | null>(null);
|
||||||
readonly chmod = signal<ChmodRequest | null>(null);
|
readonly chmod = signal<ChmodRequest | null>(null);
|
||||||
readonly overwrite = signal<OverwriteRequest | null>(null);
|
readonly overwrite = signal<OverwriteRequest | null>(null);
|
||||||
|
readonly editor = signal<EditorRequest | null>(null);
|
||||||
readonly manager = signal(false);
|
readonly manager = signal(false);
|
||||||
|
|
||||||
/** Ask what to do about one name collision; resolves with the user's choice. */
|
/** Ask what to do about one name collision; resolves with the user's choice. */
|
||||||
|
|||||||
@@ -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: `
|
||||||
|
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40">
|
||||||
|
<div
|
||||||
|
class="flex h-[80vh] w-[85vw] max-w-5xl flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xl"
|
||||||
|
(keydown)="onKeydown($event)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||||
|
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<h2 class="min-w-0 truncate font-mono text-sm font-semibold">
|
||||||
|
{{ request().name }}{{ dirty() ? ' •' : '' }}
|
||||||
|
</h2>
|
||||||
|
<span class="shrink-0 text-[11px] text-muted-foreground">
|
||||||
|
{{ request().sessionTitle }}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
(click)="close()"
|
||||||
|
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="1rem" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
#box
|
||||||
|
[value]="text()"
|
||||||
|
(input)="onInput($any($event.target).value)"
|
||||||
|
spellcheck="false"
|
||||||
|
class="min-h-0 flex-1 resize-none bg-transparent p-4 font-mono text-xs leading-relaxed outline-none"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 border-t border-border px-4 py-2.5">
|
||||||
|
<span class="text-[11px] text-muted-foreground">⌘S saves · Esc closes</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
(click)="close()"
|
||||||
|
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="save()"
|
||||||
|
[disabled]="saving() || !dirty()"
|
||||||
|
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
@if (saving()) {
|
||||||
|
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||||
|
}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class EditorDialog {
|
||||||
|
readonly request = input.required<EditorRequest>();
|
||||||
|
|
||||||
|
readonly text = signal('');
|
||||||
|
readonly dirty = signal(false);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
|
||||||
|
private readonly box = viewChild.required<ElementRef<HTMLTextAreaElement>>('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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { DialogsService } from './dialogs.service';
|
|||||||
import { joinPath } from './format';
|
import { joinPath } from './format';
|
||||||
import { type PanelState } from './panels.service';
|
import { type PanelState } from './panels.service';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +16,7 @@ export class FileActionsService {
|
|||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
private readonly dialogs = inject(DialogsService);
|
private readonly dialogs = inject(DialogsService);
|
||||||
|
private readonly settings = inject(SettingsService);
|
||||||
|
|
||||||
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
|
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
|
||||||
targets(panel: PanelState): FileEntry[] {
|
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<void> {
|
||||||
|
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 {
|
rename(panel: PanelState): void {
|
||||||
const entry = panel.sorted()[panel.cursor()];
|
const entry = panel.sorted()[panel.cursor()];
|
||||||
if (!entry || entry.name === '..') return;
|
if (!entry || entry.name === '..') return;
|
||||||
|
|||||||
@@ -37,6 +37,13 @@
|
|||||||
>
|
>
|
||||||
<ng-icon [name]="copyIcon()" size="0.95rem" />
|
<ng-icon [name]="copyIcon()" size="0.95rem" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="run('edit')"
|
||||||
|
title="Edit file (F4)"
|
||||||
|
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaFileText" size="0.9rem" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="run('rename')"
|
(click)="run('rename')"
|
||||||
title="Rename (F2)"
|
title="Rename (F2)"
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ export class FilePanel {
|
|||||||
void this.transfers.copyToOtherPanel(this.side());
|
void this.transfers.copyToOtherPanel(this.side());
|
||||||
}
|
}
|
||||||
|
|
||||||
run(action: 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
||||||
this.activate();
|
this.activate();
|
||||||
this.actions[action](this.panel());
|
void this.actions[action](this.panel());
|
||||||
}
|
}
|
||||||
|
|
||||||
pickLocal(): void {
|
pickLocal(): void {
|
||||||
@@ -186,6 +186,9 @@ export class FilePanel {
|
|||||||
case 'F2':
|
case 'F2':
|
||||||
this.actions.rename(panel);
|
this.actions.rename(panel);
|
||||||
break;
|
break;
|
||||||
|
case 'F4':
|
||||||
|
void this.actions.edit(panel);
|
||||||
|
break;
|
||||||
case 'F5':
|
case 'F5':
|
||||||
void this.transfers.copyToOtherPanel(this.side());
|
void this.transfers.copyToOtherPanel(this.side());
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
mynaEdit,
|
mynaEdit,
|
||||||
mynaFile,
|
mynaFile,
|
||||||
mynaFilePlus,
|
mynaFilePlus,
|
||||||
|
mynaFileText,
|
||||||
mynaFolder,
|
mynaFolder,
|
||||||
mynaFolderPlus,
|
mynaFolderPlus,
|
||||||
mynaHardDrive,
|
mynaHardDrive,
|
||||||
@@ -43,6 +44,7 @@ export const APP_ICONS = {
|
|||||||
mynaEdit,
|
mynaEdit,
|
||||||
mynaFile,
|
mynaFile,
|
||||||
mynaFilePlus,
|
mynaFilePlus,
|
||||||
|
mynaFileText,
|
||||||
mynaFolder,
|
mynaFolder,
|
||||||
mynaFolderPlus,
|
mynaFolderPlus,
|
||||||
mynaHardDrive,
|
mynaHardDrive,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export class SettingsService {
|
|||||||
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||||
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
||||||
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
||||||
|
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
|
||||||
|
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
|
||||||
|
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
|
|
||||||
@@ -23,6 +25,8 @@ export class SettingsService {
|
|||||||
this.transferMode.set(res.settings.transferMode);
|
this.transferMode.set(res.settings.transferMode);
|
||||||
this.transferConcurrency.set(res.settings.transferConcurrency);
|
this.transferConcurrency.set(res.settings.transferConcurrency);
|
||||||
this.autoClearCompleted.set(res.settings.autoClearCompleted);
|
this.autoClearCompleted.set(res.settings.autoClearCompleted);
|
||||||
|
this.editor.set(res.settings.editor);
|
||||||
|
this.editorApp.set(res.settings.editorApp);
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
this.applyTextSize();
|
this.applyTextSize();
|
||||||
});
|
});
|
||||||
@@ -60,6 +64,16 @@ export class SettingsService {
|
|||||||
void this.persist();
|
void this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setEditor(editor: AppSettings['editor']): void {
|
||||||
|
this.editor.set(editor);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorApp(path: string): void {
|
||||||
|
this.editorApp.set(path);
|
||||||
|
void this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
private applyTheme(): void {
|
private applyTheme(): void {
|
||||||
const theme = this.theme();
|
const theme = this.theme();
|
||||||
if (theme === 'system') {
|
if (theme === 'system') {
|
||||||
@@ -86,6 +100,8 @@ export class SettingsService {
|
|||||||
transferMode: this.transferMode(),
|
transferMode: this.transferMode(),
|
||||||
transferConcurrency: this.transferConcurrency(),
|
transferConcurrency: this.transferConcurrency(),
|
||||||
autoClearCompleted: this.autoClearCompleted(),
|
autoClearCompleted: this.autoClearCompleted(),
|
||||||
|
editor: this.editor(),
|
||||||
|
editorApp: this.editorApp(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, ElementRef, effect, inject, signal, viewChild } from '@angul
|
|||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { PanelsService } from '../panels.service';
|
import { PanelsService } from '../panels.service';
|
||||||
import { IS_MAC_CHROME } from '../platform';
|
import { IS_MAC_CHROME } from '../platform';
|
||||||
|
import { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
|
|
||||||
const THEMES = ['system', 'light', 'dark'] as const;
|
const THEMES = ['system', 'light', 'dark'] as const;
|
||||||
@@ -188,6 +189,55 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">Editor</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
<button
|
||||||
|
(click)="settings.setEditor('builtin')"
|
||||||
|
class="rounded px-2 py-0.5 text-[11px]"
|
||||||
|
[class.bg-accent]="settings.editor() === 'builtin'"
|
||||||
|
[class.text-foreground]="settings.editor() === 'builtin'"
|
||||||
|
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
||||||
|
>
|
||||||
|
Built-in
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="settings.setEditor('custom')"
|
||||||
|
class="rounded px-2 py-0.5 text-[11px]"
|
||||||
|
[class.bg-accent]="settings.editor() === 'custom'"
|
||||||
|
[class.text-foreground]="settings.editor() === 'custom'"
|
||||||
|
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (settings.editor() === 'custom') {
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
||||||
|
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
|
||||||
|
{{ editorAppName() }}
|
||||||
|
</span>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
(click)="pickEditorApp()"
|
||||||
|
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
||||||
|
>
|
||||||
|
Choose…
|
||||||
|
</button>
|
||||||
|
@if (settings.editorApp()) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setEditorApp('')"
|
||||||
|
title="Reset to system default"
|
||||||
|
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="0.7rem" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
<span class="text-xs text-muted-foreground">Finished transfers</span>
|
<span class="text-xs text-muted-foreground">Finished transfers</span>
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
@@ -219,6 +269,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
export class TabBar {
|
export class TabBar {
|
||||||
readonly panels = inject(PanelsService);
|
readonly panels = inject(PanelsService);
|
||||||
readonly settings = inject(SettingsService);
|
readonly settings = inject(SettingsService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
readonly settingsOpen = signal(false);
|
readonly settingsOpen = signal(false);
|
||||||
readonly isMacChrome = IS_MAC_CHROME;
|
readonly isMacChrome = IS_MAC_CHROME;
|
||||||
readonly themes = THEMES;
|
readonly themes = THEMES;
|
||||||
@@ -248,6 +299,19 @@ export class TabBar {
|
|||||||
this.panels.closeWorkspace(id);
|
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<void> {
|
||||||
|
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). */
|
/** Insertion line at the drop position (inset shadow: no layout shift). */
|
||||||
dropIndicatorShadow(index: number): string | null {
|
dropIndicatorShadow(index: number): string | null {
|
||||||
const target = this.dropTarget();
|
const target = this.dropTarget();
|
||||||
|
|||||||
Reference in New Issue
Block a user