Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93a9b07722 | ||
|
|
6a19097b25 | ||
|
|
1652b9e298 |
Binary file not shown.
@@ -134,6 +134,21 @@ export class EditManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Park unsaveable editor content (window closed, session dead) in the
|
||||||
|
* leftovers dir so the next-boot prompt offers it back to the user.
|
||||||
|
*/
|
||||||
|
async rescueDraft(name: string, content: string): Promise<void> {
|
||||||
|
const dir = path.join(this.editsDir, crypto.randomUUID().slice(0, 8));
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dir, "meta.json"),
|
||||||
|
JSON.stringify({ name, rescuedAt: new Date().toISOString() }),
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(dir, name), content, "utf8");
|
||||||
|
console.log(`[edit] rescued unsaved draft of ${name} -> ${dir}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Session gone (disconnect, tab close, death): stop its watchers, drop temps. */
|
/** Session gone (disconnect, tab close, death): stop its watchers, drop temps. */
|
||||||
stopForSession(sessionId: string): void {
|
stopForSession(sessionId: string): void {
|
||||||
const [mine, rest] = [
|
const [mine, rest] = [
|
||||||
|
|||||||
+52
-10
@@ -1,8 +1,10 @@
|
|||||||
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun";
|
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun";
|
||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
import { effectiveConcurrency } from "../shared/types";
|
import type { EditorOpenContext } from "../shared/rpc";
|
||||||
|
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
||||||
import { PanelContextMenu } from "./context-menu";
|
import { PanelContextMenu } from "./context-menu";
|
||||||
import { EditManager } from "./edits";
|
import { EditManager } from "./edits";
|
||||||
|
import { EditorWindows } from "./editor-windows";
|
||||||
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";
|
||||||
@@ -11,6 +13,7 @@ import { migrateLegacyDataDir } from "./store/paths";
|
|||||||
import { SettingsStore } from "./store/settings";
|
import { SettingsStore } from "./store/settings";
|
||||||
import { AppTray } from "./tray";
|
import { AppTray } from "./tray";
|
||||||
import { TransferEngine } from "./transfers/engine";
|
import { TransferEngine } from "./transfers/engine";
|
||||||
|
import { buildPreload } from "./windows";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
|
|
||||||
@@ -44,6 +47,8 @@ const contextMenu = new PanelContextMenu();
|
|||||||
const events = {
|
const events = {
|
||||||
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
||||||
connectionsChanged: () => {},
|
connectionsChanged: () => {},
|
||||||
|
openEditor: (_ctx: EditorOpenContext) => {},
|
||||||
|
settingsChanged: (_settings: AppSettings) => {},
|
||||||
};
|
};
|
||||||
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
|
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
|
||||||
const url = await getMainViewUrl();
|
const url = await getMainViewUrl();
|
||||||
@@ -66,10 +71,23 @@ const win = new BrowserWindow({
|
|||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||||
: {}),
|
: {}),
|
||||||
// lets the UI pick the fused layout before first paint
|
// lets the UI pick the fused layout before first paint
|
||||||
preload: `window.__APP_PLATFORM=${JSON.stringify(process.platform)};`,
|
preload: buildPreload("main"),
|
||||||
rpc,
|
rpc,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editors = new EditorWindows({
|
||||||
|
registry,
|
||||||
|
settings,
|
||||||
|
edits,
|
||||||
|
url,
|
||||||
|
mainFrame: () => win.getFrame(),
|
||||||
|
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
|
||||||
|
});
|
||||||
|
events.openEditor = (ctx) => editors.open(ctx);
|
||||||
|
events.settingsChanged = (s) => editors.broadcastSettings(s);
|
||||||
|
// clean editors close with the main window; dirty ones get to ask first
|
||||||
|
win.on("close", () => editors.closeAllForAppExit());
|
||||||
|
|
||||||
new AppTray(win);
|
new AppTray(win);
|
||||||
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
|
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
|
||||||
|
|
||||||
@@ -85,7 +103,11 @@ engine.onUpdate = (snapshot) => {
|
|||||||
const previous = notifiedStates.get(snapshot.id);
|
const previous = notifiedStates.get(snapshot.id);
|
||||||
if (previous !== snapshot.state) {
|
if (previous !== snapshot.state) {
|
||||||
notifiedStates.set(snapshot.id, snapshot.state);
|
notifiedStates.set(snapshot.id, snapshot.state);
|
||||||
if (!windowFocused && (snapshot.state === "completed" || snapshot.state === "failed")) {
|
if (
|
||||||
|
!windowFocused &&
|
||||||
|
!editors.anyFocused() &&
|
||||||
|
(snapshot.state === "completed" || snapshot.state === "failed")
|
||||||
|
) {
|
||||||
Utils.showNotification({
|
Utils.showNotification({
|
||||||
title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed",
|
title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed",
|
||||||
body:
|
body:
|
||||||
@@ -102,18 +124,24 @@ engine.onUpdate = (snapshot) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// don't quit out from under running transfers
|
// don't quit out from under running transfers or unsaved editors
|
||||||
let quitConfirmed = false;
|
let quitConfirmed = false;
|
||||||
Electrobun.events.on("before-quit", (event) => {
|
Electrobun.events.on("before-quit", (event) => {
|
||||||
if (quitConfirmed || !engine.hasActiveJobs()) return;
|
const transfers = engine.activeJobCount();
|
||||||
|
const dirtyEditors = editors.dirtyCount();
|
||||||
|
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
|
||||||
(event as { response: { allow: boolean } }).response = { allow: false };
|
(event as { response: { allow: boolean } }).response = { allow: false };
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const count = engine.activeJobCount();
|
const parts = [
|
||||||
|
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
|
||||||
|
dirtyEditors > 0 &&
|
||||||
|
`${dirtyEditors} file${dirtyEditors === 1 ? " has" : "s have"} unsaved changes`,
|
||||||
|
].filter(Boolean);
|
||||||
const { response } = await Utils.showMessageBox({
|
const { response } = await Utils.showMessageBox({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
message: `${count} transfer${count === 1 ? " is" : "s are"} still running.`,
|
message: `${parts.join(" and ")}.`,
|
||||||
detail: "Quitting now will abort them.",
|
detail: "Quitting now will abort transfers and discard unsaved changes.",
|
||||||
buttons: ["Quit Anyway", "Cancel"],
|
buttons: ["Quit Anyway", "Cancel"],
|
||||||
defaultId: 1,
|
defaultId: 1,
|
||||||
cancelId: 1,
|
cancelId: 1,
|
||||||
@@ -124,14 +152,28 @@ Electrobun.events.on("before-quit", (event) => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
|
events.sessionClosed = (info) => {
|
||||||
|
rpc.send.sessionClosed(info);
|
||||||
|
editors.notifySessionClosed(info);
|
||||||
|
};
|
||||||
events.connectionsChanged = () => void applyApplicationMenu(store);
|
events.connectionsChanged = () => void applyApplicationMenu(store);
|
||||||
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
|
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
|
||||||
|
|
||||||
void applyApplicationMenu(store);
|
void applyApplicationMenu(store);
|
||||||
ApplicationMenu.on("application-menu-clicked", (event) => {
|
ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||||
const action = (event as { data?: { action?: string } }).data?.action;
|
const action = (event as { data?: { action?: string } }).data?.action;
|
||||||
if (action) rpc.send.menuAction({ action });
|
if (!action) return;
|
||||||
|
// ⌘W while an editor window is focused closes that editor (with its dirty
|
||||||
|
// prompt), not a main-window tab. Other menu actions deliberately still
|
||||||
|
// target the main window.
|
||||||
|
if (action === "close-tab") {
|
||||||
|
const focusedEditor = editors.focusedKey();
|
||||||
|
if (focusedEditor) {
|
||||||
|
editors.requestClose(focusedEditor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rpc.send.menuAction({ action });
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`${BRANDING.appName} started (${url})`);
|
console.log(`${BRANDING.appName} started (${url})`);
|
||||||
|
|||||||
+63
-2
@@ -1,6 +1,6 @@
|
|||||||
import { BrowserView, Utils } from "electrobun/bun";
|
import { BrowserView, Utils } from "electrobun/bun";
|
||||||
import type { AppRPC } from "../shared/rpc";
|
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type Result } from "../shared/types";
|
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||||
import type { PanelContextMenu } from "./context-menu";
|
import type { PanelContextMenu } from "./context-menu";
|
||||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||||
import type { SessionRegistry } from "./fs/registry";
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
@@ -27,6 +27,8 @@ export interface AppDeps {
|
|||||||
events: {
|
events: {
|
||||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
||||||
connectionsChanged: () => void;
|
connectionsChanged: () => void;
|
||||||
|
openEditor: (ctx: EditorOpenContext) => void;
|
||||||
|
settingsChanged: (settings: AppSettings) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +197,7 @@ export function createAppRPC({
|
|||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
await settings.save(next);
|
await settings.save(next);
|
||||||
engine.setConcurrency(effectiveConcurrency(next));
|
engine.setConcurrency(effectiveConcurrency(next));
|
||||||
|
events.settingsChanged(next);
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -261,6 +264,64 @@ export function createAppRPC({
|
|||||||
Utils.moveToTrash(path);
|
Utils.moveToTrash(path);
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ---- editor windows ----
|
||||||
|
openEditor: (ctx) =>
|
||||||
|
wrap(async () => {
|
||||||
|
events.openEditor(ctx);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorRpcHooks {
|
||||||
|
ctx: EditorOpenContext;
|
||||||
|
/** Draft mirror tick: store dirty flag + content on the window record. */
|
||||||
|
onDraft: (state: { dirty: boolean; content: string }) => void;
|
||||||
|
/** editorSave succeeded: clean the record, retitle, refresh main panels. */
|
||||||
|
onSaved: () => void;
|
||||||
|
/** Webview resolved its dirty prompt: close without the native fallback. */
|
||||||
|
onCloseConfirmed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC for one editor window. Each window MUST get its own instance (binding a
|
||||||
|
* BrowserView steals the rpc's transport), and the hooks close over that
|
||||||
|
* window's record — no ids on the wire.
|
||||||
|
*/
|
||||||
|
export function createEditorRPC(
|
||||||
|
deps: { registry: SessionRegistry; settings: SettingsStore },
|
||||||
|
hooks: EditorRpcHooks,
|
||||||
|
) {
|
||||||
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
|
handlers: {
|
||||||
|
requests: {
|
||||||
|
readFile: ({ sessionId, path }) =>
|
||||||
|
wrap(async () => ({
|
||||||
|
content: await readFileText(deps.registry.get(sessionId), path),
|
||||||
|
})),
|
||||||
|
|
||||||
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||||
|
|
||||||
|
editorSave: ({ content }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
const { sessionId, path } = hooks.ctx;
|
||||||
|
await writeFileText(deps.registry.get(sessionId), path, content);
|
||||||
|
hooks.onSaved();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
editorClose: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
hooks.onCloseConfirmed();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
editorState: (state) => hooks.onDraft(state),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { BrowserWindow } from "electrobun/bun";
|
||||||
|
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preload globals every window gets. Single composition point: the UI
|
||||||
|
* reads them in ui/src/app/platform.ts before first paint (window chrome and
|
||||||
|
* root-component selection both depend on them).
|
||||||
|
*/
|
||||||
|
export function buildPreload(mode: "main" | "editor", ctx?: unknown): string {
|
||||||
|
let script =
|
||||||
|
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
||||||
|
`window.__APP_MODE=${JSON.stringify(mode)};`;
|
||||||
|
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecondaryWindowOptions {
|
||||||
|
title: string;
|
||||||
|
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||||
|
url: string;
|
||||||
|
mode: "editor"; // widens when more window kinds exist (connection manager…)
|
||||||
|
ctx?: unknown;
|
||||||
|
frame: { x: number; y: number; width: number; height: number };
|
||||||
|
/** Per-window rpc instance. NEVER share one across windows — each
|
||||||
|
* BrowserView steals the rpc's transport when it binds. */
|
||||||
|
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-chromed secondary window.
|
||||||
|
*
|
||||||
|
* PORTABILITY: the only spot that branches on platform for secondary-window
|
||||||
|
* chrome. macOS fuses the titlebar (traffic lights overlay the UI's h-10
|
||||||
|
* header row, same treatment as the main window); Windows/Linux keep the
|
||||||
|
* native titlebar and the UI renders a compact non-drag header instead.
|
||||||
|
*/
|
||||||
|
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||||
|
return new BrowserWindow({
|
||||||
|
title: opts.title,
|
||||||
|
url: opts.url,
|
||||||
|
frame: opts.frame,
|
||||||
|
...(isMac
|
||||||
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||||
|
: {}),
|
||||||
|
preload: buildPreload(opts.mode, opts.ctx),
|
||||||
|
rpc: opts.rpc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyed registry of secondary windows: dedupe/activate, cascade placement off
|
||||||
|
* an anchor window, focus tracking, close cleanup. A future "window follows
|
||||||
|
* window" grouping step slots in here — the registry already owns the window
|
||||||
|
* handles and the anchor it would need (anchor.on('move') + setPosition).
|
||||||
|
*/
|
||||||
|
export class WindowRegistry<Rec extends { win: BrowserWindow }> {
|
||||||
|
private records = new Map<string, Rec>();
|
||||||
|
private focused = new Set<string>();
|
||||||
|
private opened = 0;
|
||||||
|
|
||||||
|
constructor(private anchorFrame: () => { x: number; y: number }) {}
|
||||||
|
|
||||||
|
get(key: string): Rec | undefined {
|
||||||
|
return this.records.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True (and focuses the window) when the key already has a live window. */
|
||||||
|
activateExisting(key: string): boolean {
|
||||||
|
const rec = this.records.get(key);
|
||||||
|
if (!rec) return false;
|
||||||
|
rec.win.activate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, rec: Rec): void {
|
||||||
|
this.records.set(key, rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cascade off the anchor so stacked windows never hide each other fully. */
|
||||||
|
nextFrame(width: number, height: number): { x: number; y: number; width: number; height: number } {
|
||||||
|
const anchor = this.anchorFrame();
|
||||||
|
const step = (this.opened++ % 8) * 28;
|
||||||
|
return { x: anchor.x + 90 + step, y: anchor.y + 70 + step, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire close/focus/blur for a window. electrobun's emitter has no off(),
|
||||||
|
* so these closures capture only (this, key) — never the record — letting
|
||||||
|
* closed-window content be garbage collected after delete().
|
||||||
|
*/
|
||||||
|
track(key: string, win: BrowserWindow, onClose: (key: string) => void): void {
|
||||||
|
win.on("focus", () => this.focused.add(key));
|
||||||
|
win.on("blur", () => this.focused.delete(key));
|
||||||
|
win.on("close", () => {
|
||||||
|
this.focused.delete(key);
|
||||||
|
onClose(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void {
|
||||||
|
this.records.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
anyFocused(): boolean {
|
||||||
|
return this.focused.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusedKey(): string | null {
|
||||||
|
for (const key of this.focused) return key;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
forEach(fn: (rec: Rec, key: string) => void): void {
|
||||||
|
for (const [key, rec] of this.records) fn(rec, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
return this.records.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-1
@@ -11,12 +11,24 @@ import type {
|
|||||||
TransferSnapshot,
|
TransferSnapshot,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
/** Everything an editor window needs to know about the file it hosts. */
|
||||||
|
export interface EditorOpenContext {
|
||||||
|
sessionId: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
sessionTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Structurally equivalent to electrobun's ElectrobunRPCSchema, written in
|
// Structurally equivalent to electrobun's ElectrobunRPCSchema, written in
|
||||||
// resolved form so this file stays importable from the Angular bundle without
|
// resolved form so this file stays importable from the Angular bundle without
|
||||||
// pulling in bun-only type modules.
|
// pulling in bun-only type modules.
|
||||||
//
|
//
|
||||||
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
|
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
|
||||||
// webview.messages = push events from bun, handled in ui rpc.service.ts
|
// webview.messages = push events from bun, handled in ui rpc.service.ts
|
||||||
|
//
|
||||||
|
// ONE schema serves both window kinds (main + editor): electrobun allows
|
||||||
|
// partial handler sets, and messages nobody listens to are silently dropped,
|
||||||
|
// so each side only implements/hears what applies to it.
|
||||||
export type AppRPC = {
|
export type AppRPC = {
|
||||||
bun: {
|
bun: {
|
||||||
requests: {
|
requests: {
|
||||||
@@ -122,8 +134,17 @@ export type AppRPC = {
|
|||||||
response: Result;
|
response: Result;
|
||||||
};
|
};
|
||||||
moveToTrash: { params: { path: string }; response: Result };
|
moveToTrash: { params: { path: string }; response: Result };
|
||||||
|
// editor windows
|
||||||
|
/** Main window only: open (or focus) a native editor window. */
|
||||||
|
openEditor: { params: EditorOpenContext; response: Result };
|
||||||
|
/** Editor windows only — handled by that window's own rpc closure. */
|
||||||
|
editorSave: { params: { content: string }; response: Result };
|
||||||
|
editorClose: { params: Record<string, never>; response: Result };
|
||||||
|
};
|
||||||
|
messages: {
|
||||||
|
/** Editor → bun draft mirror; safety net for the non-cancellable native close. */
|
||||||
|
editorState: { dirty: boolean; content: string };
|
||||||
};
|
};
|
||||||
messages: Record<never, never>;
|
|
||||||
};
|
};
|
||||||
webview: {
|
webview: {
|
||||||
requests: Record<never, never>;
|
requests: Record<never, never>;
|
||||||
@@ -131,6 +152,12 @@ export type AppRPC = {
|
|||||||
transferUpdate: TransferSnapshot;
|
transferUpdate: TransferSnapshot;
|
||||||
sessionClosed: { sessionId: string; reason: string };
|
sessionClosed: { sessionId: string; reason: string };
|
||||||
menuAction: { action: string };
|
menuAction: { action: string };
|
||||||
|
/** bun → editor windows when the main window saves settings. */
|
||||||
|
settingsChanged: { settings: AppSettings };
|
||||||
|
/** bun → main after an editor save, so matching panels refresh. */
|
||||||
|
pathRefresh: { sessionId: string; dir: string };
|
||||||
|
/** bun → one editor: run the in-window close flow (dirty prompt). */
|
||||||
|
editorCloseRequest: Record<string, never>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-34
@@ -37,39 +37,6 @@
|
|||||||
@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 -->
|
<app-toasts />
|
||||||
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
|
||||||
@for (t of toast.toasts(); track t.id) {
|
|
||||||
<div
|
|
||||||
(mouseenter)="toast.pause(t.id)"
|
|
||||||
(mouseleave)="toast.resume(t.id)"
|
|
||||||
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
[name]="toastIcon(t.kind)"
|
|
||||||
size="1rem"
|
|
||||||
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
|
|
||||||
/>
|
|
||||||
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
|
||||||
<button
|
|
||||||
(click)="toast.dismiss(t.id)"
|
|
||||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon name="mynaX" size="0.9rem" />
|
|
||||||
</button>
|
|
||||||
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
|
||||||
<div
|
|
||||||
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
|
|
||||||
[class.bg-destructive]="t.kind === 'error'"
|
|
||||||
[class.bg-success]="t.kind === 'success'"
|
|
||||||
[class.bg-primary]="t.kind === 'info'"
|
|
||||||
[style.animation-duration.ms]="t.ttlMs"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-7
@@ -5,7 +5,6 @@ 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';
|
||||||
@@ -16,6 +15,7 @@ import { RpcService } from './rpc.service';
|
|||||||
import { StatusBar } from './status-bar/status-bar';
|
import { StatusBar } from './status-bar/status-bar';
|
||||||
import { TabBar } from './tab-bar/tab-bar';
|
import { TabBar } from './tab-bar/tab-bar';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
import { Toasts } from './toasts/toasts';
|
||||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||||
import { TransfersService } from './transfers.service';
|
import { TransfersService } from './transfers.service';
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ import { TransfersService } from './transfers.service';
|
|||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
ChmodDialog,
|
ChmodDialog,
|
||||||
OverwriteDialog,
|
OverwriteDialog,
|
||||||
EditorDialog,
|
Toasts,
|
||||||
TransferBar,
|
TransferBar,
|
||||||
ConnectionManager,
|
ConnectionManager,
|
||||||
],
|
],
|
||||||
@@ -174,9 +174,4 @@ export class App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
|
||||||
if (kind === 'error') return 'mynaXCircle';
|
|
||||||
if (kind === 'success') return 'mynaCheckCircle';
|
|
||||||
return 'mynaCheck';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -34,22 +33,12 @@ 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. */
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<div class="flex h-screen flex-col bg-background text-foreground">
|
||||||
|
@if (!ctx) {
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||||
|
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||||
|
<p class="mt-2 text-sm font-medium">No file to edit</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
This window was opened without an editor context. Launch files from the app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
||||||
|
elsewhere the native titlebar sits above and this is a compact strip -->
|
||||||
|
<header
|
||||||
|
class="flex shrink-0 items-center gap-2 border-b border-border pr-2"
|
||||||
|
[class.h-10]="isMacChrome"
|
||||||
|
[class.pl-24]="isMacChrome"
|
||||||
|
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
||||||
|
[class.h-9]="!isMacChrome"
|
||||||
|
[class.pl-3]="!isMacChrome"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
||||||
|
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
||||||
|
</h1>
|
||||||
|
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
(click)="toggleWrap()"
|
||||||
|
title="Wrap long lines — hides line numbers (⌥Z)"
|
||||||
|
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
||||||
|
[class.bg-accent]="wrap()"
|
||||||
|
[class.text-foreground]="wrap()"
|
||||||
|
[class.text-muted-foreground]="!wrap()"
|
||||||
|
>
|
||||||
|
Wrap
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="openFind()"
|
||||||
|
title="Find (⌘F)"
|
||||||
|
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaSearch" size="0.85rem" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (findOpen()) {
|
||||||
|
<div class="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
||||||
|
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
#findBox
|
||||||
|
[value]="findQuery()"
|
||||||
|
(input)="onFindInput($any($event.target).value)"
|
||||||
|
(keydown)="onFindKeydown($event)"
|
||||||
|
placeholder="Find in file"
|
||||||
|
spellcheck="false"
|
||||||
|
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="text-[11px] tabular-nums"
|
||||||
|
[class.text-destructive]="findQuery() && !matches().length"
|
||||||
|
[class.text-muted-foreground]="!findQuery() || matches().length"
|
||||||
|
>
|
||||||
|
{{ countLabel() }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
(click)="findStep(-1)"
|
||||||
|
title="Previous match (⇧↵)"
|
||||||
|
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaChevronUp" size="0.8rem" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="findStep(1)"
|
||||||
|
title="Next match (↵)"
|
||||||
|
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaChevronDown" size="0.8rem" />
|
||||||
|
</button>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
(click)="closeFind()"
|
||||||
|
title="Close (Esc)"
|
||||||
|
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="0.8rem" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (sessionDead()) {
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
|
||||||
|
Disconnected from {{ ctx.sessionTitle }} — saving will fail, but your text is preserved
|
||||||
|
in this window.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
} @else if (loadError(); as error) {
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||||
|
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||||
|
<p class="mt-2 text-sm font-medium">Can't open {{ ctx.name }}</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
|
||||||
|
<button
|
||||||
|
(click)="discardAndClose()"
|
||||||
|
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
Close Window
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<!-- gutter and textarea share font/leading/vertical padding — that's
|
||||||
|
the whole alignment contract; keep the classes in lockstep -->
|
||||||
|
@if (!wrap()) {
|
||||||
|
<div
|
||||||
|
#gutter
|
||||||
|
aria-hidden="true"
|
||||||
|
[style.width.ch]="gutterDigits() + 3"
|
||||||
|
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
|
||||||
|
>{{ gutterText() }}</div>
|
||||||
|
}
|
||||||
|
<textarea
|
||||||
|
#box
|
||||||
|
[value]="text()"
|
||||||
|
(input)="onInput($any($event.target).value)"
|
||||||
|
(scroll)="onScroll()"
|
||||||
|
spellcheck="false"
|
||||||
|
[attr.wrap]="wrap() ? 'soft' : 'off'"
|
||||||
|
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
|
||||||
|
[class.whitespace-pre]="!wrap()"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2">
|
||||||
|
<span class="text-[11px] text-muted-foreground">⌘S save · ⌘F find · ⌘W close</span>
|
||||||
|
<span class="text-[11px] text-muted-foreground">·</span>
|
||||||
|
<span class="text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
(click)="requestClose()"
|
||||||
|
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</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>
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
|
||||||
|
dialog pulls in PanelsService, which must not boot in this window. -->
|
||||||
|
@if (closePrompt()) {
|
||||||
|
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
|
||||||
|
<div
|
||||||
|
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||||
|
(mousedown)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<h2 class="text-sm font-semibold">Save changes to "{{ ctx.name }}"?</h2>
|
||||||
|
<p class="mt-1.5 text-xs text-muted-foreground">
|
||||||
|
Your changes will be lost if you don't save them.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
(click)="discardAndClose()"
|
||||||
|
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
|
||||||
|
>
|
||||||
|
Don't Save
|
||||||
|
</button>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
(click)="cancelClose()"
|
||||||
|
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="saveAndClose()"
|
||||||
|
[disabled]="saving()"
|
||||||
|
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-toasts />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
DOCUMENT,
|
||||||
|
ElementRef,
|
||||||
|
afterNextRender,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { EDITOR_CONTEXT, IS_MAC_CHROME } from '../platform';
|
||||||
|
import { RpcService } from '../rpc.service';
|
||||||
|
import { SettingsService } from '../settings.service';
|
||||||
|
import { ToastService } from '../toast.service';
|
||||||
|
import { Toasts } from '../toasts/toasts';
|
||||||
|
|
||||||
|
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
||||||
|
const DRAFT_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root component of an editor window (window.__APP_MODE === 'editor').
|
||||||
|
*
|
||||||
|
* Deliberately injects only Rpc/Toast/Settings: main-window root services
|
||||||
|
* (PanelsService, ConnectionsService, TransfersService — and the dialogs that
|
||||||
|
* inject them) have constructor side effects like creating workspaces and
|
||||||
|
* loading connections that must not run here.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [NgIcon, Toasts],
|
||||||
|
templateUrl: './editor-window.html',
|
||||||
|
host: { '(window:keydown)': 'onKeydown($event)', '(window:blur)': 'flushDraft()' },
|
||||||
|
})
|
||||||
|
export class EditorWindow {
|
||||||
|
readonly ctx = EDITOR_CONTEXT;
|
||||||
|
readonly isMacChrome = IS_MAC_CHROME;
|
||||||
|
|
||||||
|
readonly loading = signal(true);
|
||||||
|
readonly loadError = signal<string | null>(null);
|
||||||
|
readonly text = signal('');
|
||||||
|
readonly savedContent = signal('');
|
||||||
|
readonly dirty = computed(() => this.text() !== this.savedContent());
|
||||||
|
readonly saving = signal(false);
|
||||||
|
readonly closePrompt = signal(false);
|
||||||
|
readonly sessionDead = signal(false);
|
||||||
|
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
|
||||||
|
* would need per-edit height measurement — not worth it without an editor lib. */
|
||||||
|
readonly wrap = signal(false);
|
||||||
|
readonly findOpen = signal(false);
|
||||||
|
readonly findQuery = signal('');
|
||||||
|
readonly activeMatch = signal(0);
|
||||||
|
|
||||||
|
/** Line count without allocating a split array on every keystroke. */
|
||||||
|
readonly lineCount = computed(() => {
|
||||||
|
const t = this.text();
|
||||||
|
let n = 1;
|
||||||
|
let i = -1;
|
||||||
|
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
readonly gutterText = computed(() =>
|
||||||
|
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
|
||||||
|
);
|
||||||
|
readonly gutterDigits = computed(() => String(this.lineCount()).length);
|
||||||
|
|
||||||
|
/** Case-insensitive match start offsets for the find query. */
|
||||||
|
readonly matches = computed(() => {
|
||||||
|
const query = this.findQuery().toLowerCase();
|
||||||
|
if (!query) return [];
|
||||||
|
const hay = this.text().toLowerCase();
|
||||||
|
const out: number[] = [];
|
||||||
|
let i = hay.indexOf(query);
|
||||||
|
while (i !== -1) {
|
||||||
|
out.push(i);
|
||||||
|
i = hay.indexOf(query, i + query.length);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
readonly countLabel = computed(() =>
|
||||||
|
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
|
||||||
|
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
||||||
|
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
||||||
|
private cachedCharWidth = 0;
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
// injected for its side effects: fetches settings and applies theme/accent/
|
||||||
|
// text-size data attributes to this window's document root
|
||||||
|
private readonly settings = inject(SettingsService);
|
||||||
|
|
||||||
|
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private lastSentDirty = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
if (!ctx) return; // opened outside the app (plain browser) — error card renders
|
||||||
|
|
||||||
|
const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`;
|
||||||
|
effect(() => {
|
||||||
|
this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rpc.onEditorCloseRequest(() => this.requestClose());
|
||||||
|
this.rpc.onSessionClosed((event) => {
|
||||||
|
if (event.sessionId === ctx.sessionId) this.sessionDead.set(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(sessionId: string, path: string): Promise<void> {
|
||||||
|
const res = await this.rpc.request.readFile({ sessionId, path });
|
||||||
|
if (!res.ok) {
|
||||||
|
this.loadError.set(res.error);
|
||||||
|
this.loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.savedContent.set(res.content);
|
||||||
|
this.text.set(res.content);
|
||||||
|
this.loading.set(false);
|
||||||
|
queueMicrotask(() => this.box()?.nativeElement.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
onInput(value: string): void {
|
||||||
|
this.text.set(value);
|
||||||
|
this.scheduleDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the line-number gutter locked to the textarea's scroll position. */
|
||||||
|
onScroll(): void {
|
||||||
|
const gutter = this.gutter()?.nativeElement;
|
||||||
|
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleWrap(): void {
|
||||||
|
this.wrap.update((w) => !w);
|
||||||
|
queueMicrotask(() => this.onScroll());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- find ----
|
||||||
|
|
||||||
|
openFind(): void {
|
||||||
|
const el = this.box()?.nativeElement;
|
||||||
|
// prefill from the current selection when it's a sane single-line snippet
|
||||||
|
if (el && el.selectionStart !== el.selectionEnd) {
|
||||||
|
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
|
||||||
|
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
|
||||||
|
}
|
||||||
|
this.findOpen.set(true);
|
||||||
|
this.activeMatch.set(0);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const input = this.findBox()?.nativeElement;
|
||||||
|
input?.focus();
|
||||||
|
input?.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeFind(): void {
|
||||||
|
this.findOpen.set(false);
|
||||||
|
// selection was left on the match — the caret lands there
|
||||||
|
this.box()?.nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFindInput(value: string): void {
|
||||||
|
this.findQuery.set(value);
|
||||||
|
const list = this.matches();
|
||||||
|
if (!list.length) return;
|
||||||
|
// jump to the first match at/after the caret, else wrap to the first
|
||||||
|
const from = this.box()?.nativeElement.selectionStart ?? 0;
|
||||||
|
const at = list.findIndex((start) => start >= from);
|
||||||
|
this.goTo(at >= 0 ? at : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
findStep(delta: 1 | -1): void {
|
||||||
|
const count = this.matches().length;
|
||||||
|
if (!count) return;
|
||||||
|
this.goTo((this.activeMatch() + delta + count) % count);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFindKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.findStep(event.shiftKey ? -1 : 1);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.closeFind();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select match `index` and scroll it into the middle of the viewport. */
|
||||||
|
private goTo(index: number): void {
|
||||||
|
const el = this.box()?.nativeElement;
|
||||||
|
const start = this.matches()[index];
|
||||||
|
if (!el || start === undefined) return;
|
||||||
|
this.activeMatch.set(index);
|
||||||
|
el.setSelectionRange(start, start + this.findQuery().length);
|
||||||
|
|
||||||
|
const text = this.text();
|
||||||
|
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
|
||||||
|
let line = 0;
|
||||||
|
let i = text.indexOf('\n');
|
||||||
|
while (i !== -1 && i < start) {
|
||||||
|
line++;
|
||||||
|
i = text.indexOf('\n', i + 1);
|
||||||
|
}
|
||||||
|
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
|
||||||
|
if (!this.wrap()) {
|
||||||
|
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
|
||||||
|
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
|
||||||
|
}
|
||||||
|
this.onScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monospace advance width, measured once per window. */
|
||||||
|
private charWidth(el: HTMLTextAreaElement): number {
|
||||||
|
if (!this.cachedCharWidth) {
|
||||||
|
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
||||||
|
canvas.font = getComputedStyle(el).font;
|
||||||
|
this.cachedCharWidth = canvas.measureText('0').width || 7;
|
||||||
|
}
|
||||||
|
return this.cachedCharWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
|
||||||
|
|
||||||
|
private scheduleDraft(): void {
|
||||||
|
if (this.dirty() !== this.lastSentDirty) {
|
||||||
|
// dirty flag flipped — tell bun immediately so the native title updates
|
||||||
|
this.flushDraft();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.draftTimer) clearTimeout(this.draftTimer);
|
||||||
|
this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushDraft(): void {
|
||||||
|
if (!this.ctx || this.loading()) return;
|
||||||
|
if (this.draftTimer) {
|
||||||
|
clearTimeout(this.draftTimer);
|
||||||
|
this.draftTimer = null;
|
||||||
|
}
|
||||||
|
this.lastSentDirty = this.dirty();
|
||||||
|
this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- save / close ----
|
||||||
|
|
||||||
|
async save(): Promise<boolean> {
|
||||||
|
if (!this.dirty() || this.saving()) return true;
|
||||||
|
const content = this.text();
|
||||||
|
this.saving.set(true);
|
||||||
|
try {
|
||||||
|
const res = await this.rpc.request.editorSave({ content });
|
||||||
|
if (!res.ok) {
|
||||||
|
this.toast.error(res.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.savedContent.set(content);
|
||||||
|
this.lastSentDirty = false; // bun record already cleaned by the handler
|
||||||
|
this.toast.success(`Saved ${this.ctx!.name}`);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
this.saving.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */
|
||||||
|
requestClose(): void {
|
||||||
|
if (this.dirty()) {
|
||||||
|
this.closePrompt.set(true);
|
||||||
|
} else {
|
||||||
|
void this.rpc.request.editorClose({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAndClose(): Promise<void> {
|
||||||
|
if (await this.save()) void this.rpc.request.editorClose({});
|
||||||
|
}
|
||||||
|
|
||||||
|
discardAndClose(): void {
|
||||||
|
void this.rpc.request.editorClose({});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelClose(): void {
|
||||||
|
this.closePrompt.set(false);
|
||||||
|
queueMicrotask(() => this.box()?.nativeElement.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keyboard ----
|
||||||
|
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
const mod = event.metaKey || event.ctrlKey;
|
||||||
|
if (mod && event.key.toLowerCase() === 's') {
|
||||||
|
event.preventDefault();
|
||||||
|
void this.save();
|
||||||
|
} else if (mod && event.key.toLowerCase() === 'f') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.openFind();
|
||||||
|
} else if (mod && event.key.toLowerCase() === 'w') {
|
||||||
|
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and
|
||||||
|
// bun routes it back as editorCloseRequest
|
||||||
|
event.preventDefault();
|
||||||
|
this.requestClose();
|
||||||
|
} else if (event.altKey && event.code === 'KeyZ') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.toggleWrap();
|
||||||
|
} else if (event.key === 'Escape' && this.closePrompt()) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.cancelClose();
|
||||||
|
} else if (event.key === 'Escape' && this.findOpen()) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.closeFind();
|
||||||
|
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
|
||||||
|
// keep Tab as an input character inside the editor
|
||||||
|
event.preventDefault();
|
||||||
|
this.insertTab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertTab(): void {
|
||||||
|
const el = this.box()!.nativeElement;
|
||||||
|
// execCommand keeps the native undo stack intact where supported
|
||||||
|
if (!this.document.execCommand('insertText', false, '\t')) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,22 +105,14 @@ export class FileActionsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await this.rpc.request.readFile({
|
// built-in editor: its own native window (reads the file itself)
|
||||||
sessionId: panel.sessionId(),
|
const res = await this.rpc.request.openEditor({
|
||||||
path: entry.path,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
this.toast.error(res.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.dialogs.editor.set({
|
|
||||||
sessionId: panel.sessionId(),
|
sessionId: panel.sessionId(),
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
sessionTitle: panel.title(),
|
sessionTitle: panel.title(),
|
||||||
content: res.content,
|
|
||||||
panel,
|
|
||||||
});
|
});
|
||||||
|
if (!res.ok) this.toast.error(res.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
rename(panel: PanelState): void {
|
rename(panel: PanelState): void {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
mynaCheckCircle,
|
mynaCheckCircle,
|
||||||
mynaChevronDown,
|
mynaChevronDown,
|
||||||
mynaChevronRight,
|
mynaChevronRight,
|
||||||
|
mynaChevronUp,
|
||||||
mynaCode,
|
mynaCode,
|
||||||
mynaCog,
|
mynaCog,
|
||||||
mynaDots,
|
mynaDots,
|
||||||
@@ -46,6 +47,7 @@ export const APP_ICONS = {
|
|||||||
mynaCheckCircle,
|
mynaCheckCircle,
|
||||||
mynaChevronDown,
|
mynaChevronDown,
|
||||||
mynaChevronRight,
|
mynaChevronRight,
|
||||||
|
mynaChevronUp,
|
||||||
mynaCode,
|
mynaCode,
|
||||||
mynaCog,
|
mynaCog,
|
||||||
mynaDots,
|
mynaDots,
|
||||||
|
|||||||
@@ -223,6 +223,13 @@ export class PanelsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// an editor window saved a file — refresh panels showing that directory
|
||||||
|
this.rpc.onPathRefresh(({ sessionId, dir }) => {
|
||||||
|
for (const panel of this.allPanels()) {
|
||||||
|
if (panel.sessionId() === sessionId && panel.cwd() === dir) void panel.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- panels of the active workspace ----
|
// ---- panels of the active workspace ----
|
||||||
|
|||||||
+16
-3
@@ -1,12 +1,25 @@
|
|||||||
// Injected by the bun process via BrowserWindow preload before any page script.
|
// Injected by the bun process via BrowserWindow preload (src/bun/windows.ts
|
||||||
// Brand-neutral name so rebrands never touch this file.
|
// buildPreload) before any page script. Brand-neutral names so rebrands never
|
||||||
|
// touch this file.
|
||||||
|
import type { EditorOpenContext } from '@shared/rpc';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__APP_PLATFORM?: string;
|
__APP_PLATFORM?: string;
|
||||||
|
/** Which root component this window boots: the full app or one editor. */
|
||||||
|
__APP_MODE?: 'main' | 'editor';
|
||||||
|
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||||
|
__APP_CTX?: unknown;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
|
||||||
|
|
||||||
/** True when the window uses macOS hiddenInset chrome (tab bar fused with titlebar). */
|
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
|
||||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
||||||
|
|
||||||
|
export const APP_MODE: 'main' | 'editor' =
|
||||||
|
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||||
|
|
||||||
|
export const EDITOR_CONTEXT =
|
||||||
|
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Electroview } from 'electrobun/view';
|
import { Electroview } from 'electrobun/view';
|
||||||
import type { AppRPC } from '@shared/rpc';
|
import type { AppRPC } from '@shared/rpc';
|
||||||
import type { TransferSnapshot } from '@shared/types';
|
import type { AppSettings, TransferSnapshot } from '@shared/types';
|
||||||
|
|
||||||
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
|
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
|
||||||
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
||||||
@@ -19,6 +19,9 @@ export class RpcService {
|
|||||||
private transferListeners = new Set<(s: TransferSnapshot) => void>();
|
private transferListeners = new Set<(s: TransferSnapshot) => void>();
|
||||||
private closedListeners = new Set<(e: SessionClosedEvent) => void>();
|
private closedListeners = new Set<(e: SessionClosedEvent) => void>();
|
||||||
private menuListeners = new Set<(action: string) => void>();
|
private menuListeners = new Set<(action: string) => void>();
|
||||||
|
private settingsListeners = new Set<(settings: AppSettings) => void>();
|
||||||
|
private pathRefreshListeners = new Set<(e: { sessionId: string; dir: string }) => void>();
|
||||||
|
private editorCloseListeners = new Set<() => void>();
|
||||||
|
|
||||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||||
private ready: Promise<void> = Promise.resolve();
|
private ready: Promise<void> = Promise.resolve();
|
||||||
@@ -40,6 +43,15 @@ export class RpcService {
|
|||||||
menuAction: ({ action }) => {
|
menuAction: ({ action }) => {
|
||||||
for (const listener of this.menuListeners) listener(action);
|
for (const listener of this.menuListeners) listener(action);
|
||||||
},
|
},
|
||||||
|
settingsChanged: ({ settings }) => {
|
||||||
|
for (const listener of this.settingsListeners) listener(settings);
|
||||||
|
},
|
||||||
|
pathRefresh: (event) => {
|
||||||
|
for (const listener of this.pathRefreshListeners) listener(event);
|
||||||
|
},
|
||||||
|
editorCloseRequest: () => {
|
||||||
|
for (const listener of this.editorCloseListeners) listener();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -68,6 +80,18 @@ export class RpcService {
|
|||||||
this.menuListeners.add(listener);
|
this.menuListeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSettingsChanged(listener: (settings: AppSettings) => void): void {
|
||||||
|
this.settingsListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPathRefresh(listener: (event: { sessionId: string; dir: string }) => void): void {
|
||||||
|
this.pathRefreshListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditorCloseRequest(listener: () => void): void {
|
||||||
|
this.editorCloseListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
||||||
get request() {
|
get request() {
|
||||||
const rpc = this.rpc;
|
const rpc = this.rpc;
|
||||||
@@ -87,4 +111,20 @@ export class RpcService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget message proxy: `rpc.send.editorState({...})`. Waits for the bridge. */
|
||||||
|
get send() {
|
||||||
|
const rpc = this.rpc;
|
||||||
|
if (!rpc) throw new Error('RPC is only available inside the desktop app');
|
||||||
|
const ready = this.ready;
|
||||||
|
return new Proxy({} as (typeof rpc)['send'], {
|
||||||
|
get:
|
||||||
|
(_target, method: string) =>
|
||||||
|
(payload?: unknown) => {
|
||||||
|
void ready.then(() => {
|
||||||
|
const methods = rpc.send as unknown as Record<string, (p?: unknown) => void>;
|
||||||
|
methods[method](payload);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,23 +32,28 @@ export class SettingsService {
|
|||||||
this.applyAccent();
|
this.applyAccent();
|
||||||
if (!this.rpc.available) return;
|
if (!this.rpc.available) return;
|
||||||
void this.rpc.request.getSettings({}).then((res) => {
|
void this.rpc.request.getSettings({}).then((res) => {
|
||||||
if (!res.ok) return;
|
if (res.ok) this.applyAll(res.settings);
|
||||||
this.theme.set(res.settings.theme);
|
|
||||||
this.darkTone.set(res.settings.darkTone);
|
|
||||||
this.accent.set(res.settings.accent);
|
|
||||||
this.iconColors.set(res.settings.iconColors);
|
|
||||||
this.speedUnit.set(res.settings.speedUnit);
|
|
||||||
this.textSize.set(res.settings.textSize);
|
|
||||||
this.transferMode.set(res.settings.transferMode);
|
|
||||||
this.transferConcurrency.set(res.settings.transferConcurrency);
|
|
||||||
this.autoClearCompleted.set(res.settings.autoClearCompleted);
|
|
||||||
this.editor.set(res.settings.editor);
|
|
||||||
this.editorApp.set(res.settings.editorApp);
|
|
||||||
this.applyTheme();
|
|
||||||
this.applyDarkTone();
|
|
||||||
this.applyAccent();
|
|
||||||
this.applyTextSize();
|
|
||||||
});
|
});
|
||||||
|
// editor windows: follow settings the main window saves, live
|
||||||
|
this.rpc.onSettingsChanged((settings) => this.applyAll(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAll(settings: AppSettings): void {
|
||||||
|
this.theme.set(settings.theme);
|
||||||
|
this.darkTone.set(settings.darkTone);
|
||||||
|
this.accent.set(settings.accent);
|
||||||
|
this.iconColors.set(settings.iconColors);
|
||||||
|
this.speedUnit.set(settings.speedUnit);
|
||||||
|
this.textSize.set(settings.textSize);
|
||||||
|
this.transferMode.set(settings.transferMode);
|
||||||
|
this.transferConcurrency.set(settings.transferConcurrency);
|
||||||
|
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||||
|
this.editor.set(settings.editor);
|
||||||
|
this.editorApp.set(settings.editorApp);
|
||||||
|
this.applyTheme();
|
||||||
|
this.applyDarkTone();
|
||||||
|
this.applyAccent();
|
||||||
|
this.applyTextSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(theme: AppSettings['theme']): void {
|
setTheme(theme: AppSettings['theme']): void {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { ToastService } from '../toast.service';
|
||||||
|
|
||||||
|
/** Toast stack overlay; shared by the main window and editor windows. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-toasts',
|
||||||
|
imports: [NgIcon],
|
||||||
|
template: `
|
||||||
|
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
||||||
|
@for (t of toast.toasts(); track t.id) {
|
||||||
|
<div
|
||||||
|
(mouseenter)="toast.pause(t.id)"
|
||||||
|
(mouseleave)="toast.resume(t.id)"
|
||||||
|
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
[name]="toastIcon(t.kind)"
|
||||||
|
size="1rem"
|
||||||
|
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
||||||
|
<button
|
||||||
|
(click)="toast.dismiss(t.id)"
|
||||||
|
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="0.9rem" />
|
||||||
|
</button>
|
||||||
|
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
||||||
|
<div
|
||||||
|
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
|
||||||
|
[class.bg-destructive]="t.kind === 'error'"
|
||||||
|
[class.bg-success]="t.kind === 'success'"
|
||||||
|
[class.bg-primary]="t.kind === 'info'"
|
||||||
|
[style.animation-duration.ms]="t.ttlMs"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class Toasts {
|
||||||
|
readonly toast = inject(ToastService);
|
||||||
|
|
||||||
|
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
||||||
|
if (kind === 'error') return 'mynaXCircle';
|
||||||
|
if (kind === 'success') return 'mynaCheckCircle';
|
||||||
|
return 'mynaCheck';
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -1,5 +1,9 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
import { EditorWindow } from './app/editor-window/editor-window';
|
||||||
|
import { APP_MODE } from './app/platform';
|
||||||
|
|
||||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
// One bundle, two window kinds: the preload global picks the root component.
|
||||||
|
const root = APP_MODE === 'editor' ? EditorWindow : App;
|
||||||
|
bootstrapApplication(root, appConfig).catch((err) => console.error(err));
|
||||||
|
|||||||
Reference in New Issue
Block a user