Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6d888ca9 | ||
|
|
73b27ad7c4 | ||
|
|
4090483738 | ||
|
|
9bf4aed44f | ||
|
|
a5b5f15343 | ||
|
|
5b26a4a447 | ||
|
|
6565e984b4 | ||
|
|
587071cfea | ||
|
|
08f00ed143 | ||
|
|
b9e9a0d59c | ||
|
|
d02e91a14d | ||
|
|
f38afd8bfc | ||
|
|
c3dabbfef2 | ||
|
|
d5c937b7a9 | ||
|
|
c32a8e603f |
3
.gitignore
vendored
@@ -55,6 +55,9 @@ coverage
|
||||
# Serena MCP project config (local only)
|
||||
/.serena/
|
||||
|
||||
# Git worktrees (local isolated workspaces)
|
||||
/.worktrees/
|
||||
|
||||
# Windows VS Build environment scripts (local dev only)
|
||||
Directory.Build.props
|
||||
Directory.Build.targets
|
||||
|
||||
112
App.tsx
@@ -1,5 +1,5 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useImmersiveMode } from './application/state/useImmersiveMode';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
@@ -10,6 +10,7 @@ import { useSettingsState } from './application/state/useSettingsState';
|
||||
import { useUpdateCheck } from './application/state/useUpdateCheck';
|
||||
import { useVaultState } from './application/state/useVaultState';
|
||||
import { useWindowControls } from './application/state/useWindowControls';
|
||||
import { useEditorTabs, editorTabStore } from './application/state/editorTabStore';
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
@@ -54,6 +55,9 @@ import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, Termi
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
import { TextEditorTabView } from './components/editor/TextEditorTabView';
|
||||
import { UnsavedChangesProvider } from './components/editor/UnsavedChangesDialog';
|
||||
import { editorSftpWrite } from './application/state/editorSftpBridge';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
@@ -330,6 +334,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
const editorTabs = useEditorTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.showSftpTab && activeTabId === 'sftp') {
|
||||
@@ -869,6 +874,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onCheckDirtyEditors) return;
|
||||
const unsub = bridge.onCheckDirtyEditors(() => {
|
||||
const hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
});
|
||||
return unsub;
|
||||
}, [t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -1009,6 +1027,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const activeSidePanelTabRef = useRef<string | null>(null);
|
||||
const closeTabInFlightRef = useRef(false);
|
||||
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
|
||||
// dispatcher (defined outside that scope) can still reach the dirty-confirm
|
||||
// close flow.
|
||||
const handleRequestCloseEditorTabRef = useRef<(id: string) => void>(() => {});
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
@@ -1127,13 +1149,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
// doesn't land on a hidden tab (which would get redirected back) and so
|
||||
// number shortcuts don't shift.
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs]
|
||||
: ['vault', ...orderedTabs];
|
||||
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
|
||||
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
@@ -1172,6 +1194,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
// Editor tabs route through their own dirty-confirm close flow.
|
||||
if (isEditorTabId(currentId)) {
|
||||
const editorId = fromEditorTabId(currentId);
|
||||
if (editorId) handleRequestCloseEditorTabRef.current(editorId);
|
||||
break;
|
||||
}
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
@@ -1333,7 +1362,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
|
||||
}, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -1687,7 +1716,59 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Combined ordered tab list including editor tab ids (for TopTabs scrollable area)
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => [...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))],
|
||||
[orderedTabs, editorTabs],
|
||||
);
|
||||
|
||||
return (
|
||||
<UnsavedChangesProvider>
|
||||
{({ prompt }) => {
|
||||
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
||||
const closeEditorAndActivateNeighbor = (id: string) => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditors;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
try {
|
||||
editorTabStore.setSavingState(id, 'saving');
|
||||
await editorSftpWrite(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
|
||||
editorTabStore.markSaved(id, tab.content);
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Save failed';
|
||||
editorTabStore.setSavingState(id, 'error', msg);
|
||||
toast.error(msg, 'SFTP');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
@@ -1697,7 +1778,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
orphanSessions={orphanSessions}
|
||||
workspaces={workspaces}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabs}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
draggingSessionId={draggingSessionId}
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
@@ -1716,6 +1797,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
editorTabs={editorTabs}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
@@ -1860,6 +1944,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
<TextEditorTabView
|
||||
key={tab.id}
|
||||
tabId={tab.id}
|
||||
isVisible={activeTabId === toEditorTabId(tab.id)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Global "quick add / edit snippet" dialog, triggered by the
|
||||
@@ -2106,6 +2203,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</UnsavedChangesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1780,6 +1780,12 @@ const en: Messages = {
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
'sftp.editor.maximize': 'Maximize',
|
||||
'sftp.editor.unsavedTitle': 'Unsaved changes',
|
||||
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
|
||||
'sftp.editor.discardChanges': 'Discard',
|
||||
'sftp.editor.saveAndClose': 'Save and close',
|
||||
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
|
||||
@@ -1789,6 +1789,12 @@ const zhCN: Messages = {
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
'sftp.editor.maximize': '最大化',
|
||||
'sftp.editor.unsavedTitle': '未保存的修改',
|
||||
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
|
||||
'sftp.editor.discardChanges': '不保存',
|
||||
'sftp.editor.saveAndClose': '保存并关闭',
|
||||
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
|
||||
@@ -3,6 +3,18 @@ import { useCallback,useSyncExternalStore } from 'react';
|
||||
// Simple store for active tab that allows fine-grained subscriptions
|
||||
type Listener = () => void;
|
||||
|
||||
// ----- Editor tab id helpers -----
|
||||
export const EDITOR_PREFIX = 'editor:';
|
||||
|
||||
/** Returns true when `id` is an editor tab id (starts with "editor:"). */
|
||||
export const isEditorTabId = (id: string): boolean => id.startsWith(EDITOR_PREFIX);
|
||||
|
||||
/** Convert an editorTab's internal id to a top-tab id understood by the tab bar. */
|
||||
export const toEditorTabId = (editorId: string): string => `${EDITOR_PREFIX}${editorId}`;
|
||||
|
||||
/** Strip the "editor:" prefix to recover the internal editorTab id. */
|
||||
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
|
||||
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private listeners = new Set<Listener>();
|
||||
@@ -70,9 +82,17 @@ export const useIsSftpActive = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Check if a specific editor tab is currently active
|
||||
export const useIsEditorTabActive = (tabId: string): boolean => {
|
||||
const editorTopId = toEditorTabId(tabId);
|
||||
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
// Check if terminal layer should be visible
|
||||
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
|
||||
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp';
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
};
|
||||
|
||||
69
application/state/editorSftpBridge.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { SftpFilenameEncoding } from "../../types";
|
||||
|
||||
export interface EditorSftpWrite {
|
||||
(
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
// `useSftpState` is instantiated in at least two places (the top-level SftpView
|
||||
// and the per-terminal SftpSidePanel), each owning its own pane registry. An
|
||||
// editor tab opened from either path must be saved via the matching instance,
|
||||
// so the bridge tracks all currently-mounted writers and dispatches by
|
||||
// attempting each in turn until one succeeds.
|
||||
//
|
||||
// Each writer throws synchronously (or rejects) if the connectionId isn't in
|
||||
// its pane registry; we use "connection no longer available" text as the
|
||||
// signal to fall through to the next writer. Any other error is re-thrown
|
||||
// immediately because it represents a real save failure the user must see.
|
||||
const writers = new Set<EditorSftpWrite>();
|
||||
|
||||
const NOT_MY_CONNECTION_RE = /SFTP connection is no longer available/i;
|
||||
|
||||
export const registerEditorSftpWriter = (fn: EditorSftpWrite | null) => {
|
||||
// Pass `null` on cleanup — but cleanup also needs to know WHICH writer to
|
||||
// remove. Callers who register once per mount should instead use
|
||||
// `registerEditorSftpWriterScoped` below, which returns an unregister fn.
|
||||
// This legacy signature is preserved for callers that prefer the
|
||||
// register/unregister-with-null pattern: we clear ALL writers on null.
|
||||
if (fn === null) {
|
||||
writers.clear();
|
||||
return;
|
||||
}
|
||||
writers.add(fn);
|
||||
};
|
||||
|
||||
export const registerEditorSftpWriterScoped = (fn: EditorSftpWrite): (() => void) => {
|
||||
writers.add(fn);
|
||||
return () => {
|
||||
writers.delete(fn);
|
||||
};
|
||||
};
|
||||
|
||||
export const editorSftpWrite: EditorSftpWrite = async (...args) => {
|
||||
if (writers.size === 0) {
|
||||
throw new Error("SFTP editor bridge not registered — cannot save (no SFTP view mounted)");
|
||||
}
|
||||
let lastNotMine: Error | null = null;
|
||||
for (const fn of writers) {
|
||||
try {
|
||||
await fn(...args);
|
||||
return;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (NOT_MY_CONNECTION_RE.test(msg)) {
|
||||
// This writer doesn't own the connectionId — try the next one.
|
||||
lastNotMine = err instanceof Error ? err : new Error(msg);
|
||||
continue;
|
||||
}
|
||||
// Real save error — surface it.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// No writer owned the connectionId.
|
||||
throw lastNotMine ?? new Error("SFTP connection is no longer available");
|
||||
};
|
||||
198
application/state/editorTabStore.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
|
||||
|
||||
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
|
||||
id: "edt_1",
|
||||
kind: "editor",
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "worker_processes auto;",
|
||||
baselineContent: "worker_processes auto;",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
store.updateContent("edt_1", "worker_processes 4;", null);
|
||||
const tab = store.getTab("edt_1")!;
|
||||
assert.equal(tab.content, "worker_processes 4;");
|
||||
assert.equal(store.isDirty("edt_1"), true);
|
||||
});
|
||||
|
||||
test("markSaved moves baseline to current content and clears dirty", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ content: "changed", baselineContent: "orig" }));
|
||||
assert.equal(store.isDirty("edt_1"), true);
|
||||
store.markSaved("edt_1", "changed");
|
||||
assert.equal(store.isDirty("edt_1"), false);
|
||||
assert.equal(store.getTab("edt_1")!.baselineContent, "changed");
|
||||
});
|
||||
|
||||
test("setWordWrap updates only that tab", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1" }));
|
||||
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
|
||||
store.setWordWrap("edt_1", true);
|
||||
assert.equal(store.getTab("edt_1")!.wordWrap, true);
|
||||
assert.equal(store.getTab("edt_2")!.wordWrap, false);
|
||||
});
|
||||
|
||||
test("setSavingState transitions and clears error on idle", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
store.setSavingState("edt_1", "saving");
|
||||
assert.equal(store.getTab("edt_1")!.savingState, "saving");
|
||||
store.setSavingState("edt_1", "error", "EACCES");
|
||||
assert.equal(store.getTab("edt_1")!.saveError, "EACCES");
|
||||
store.setSavingState("edt_1", "idle");
|
||||
assert.equal(store.getTab("edt_1")!.saveError, null);
|
||||
});
|
||||
|
||||
test("close removes the tab and returns remaining ids in order", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1" }));
|
||||
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
|
||||
store.close("edt_1");
|
||||
assert.equal(store.getTab("edt_1"), undefined);
|
||||
assert.deepEqual(store.getTabs().map((t) => t.id), ["edt_2"]);
|
||||
});
|
||||
|
||||
test("subscribers fire on change and not on read", () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
let count = 0;
|
||||
const unsub = store.subscribe(() => { count++; });
|
||||
store.getTab("edt_1");
|
||||
store.getTabs();
|
||||
assert.equal(count, 0);
|
||||
store.updateContent("edt_1", "x", null);
|
||||
// notifications are microtask-deferred, flush via awaiting a resolved promise
|
||||
return Promise.resolve().then(() => {
|
||||
assert.equal(count, 1);
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
|
||||
test("promoteFromModal creates a new tab and returns its id", () => {
|
||||
const store = new EditorTabStore();
|
||||
const id = store.promoteFromModal({
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "x",
|
||||
baselineContent: "x",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
});
|
||||
const tab = store.getTab(id)!;
|
||||
assert.equal(tab.remotePath, "/etc/nginx/nginx.conf");
|
||||
assert.equal(tab.fileName, "nginx.conf");
|
||||
assert.equal(tab.kind, "editor");
|
||||
});
|
||||
|
||||
test("promoteFromModal focuses existing tab for same sessionId+normalized path and overrides content", () => {
|
||||
const store = new EditorTabStore();
|
||||
const first = store.promoteFromModal({
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/./nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "v1",
|
||||
baselineContent: "v1",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
});
|
||||
const second = store.promoteFromModal({
|
||||
sessionId: "conn_1",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/nginx/nginx.conf",
|
||||
fileName: "nginx.conf",
|
||||
languageId: "ini",
|
||||
content: "v2",
|
||||
baselineContent: "v1",
|
||||
wordWrap: false,
|
||||
viewState: null,
|
||||
});
|
||||
assert.equal(second, first);
|
||||
assert.equal(store.getTab(first)!.content, "v2");
|
||||
assert.equal(store.getTabs().length, 1);
|
||||
});
|
||||
|
||||
test("dedup scope is per-sessionId — same path on different sessions are distinct tabs", () => {
|
||||
const store = new EditorTabStore();
|
||||
const a = store.promoteFromModal({
|
||||
sessionId: "conn_A",
|
||||
hostId: "host_1",
|
||||
remotePath: "/etc/hosts",
|
||||
fileName: "hosts",
|
||||
languageId: "plaintext",
|
||||
content: "", baselineContent: "", wordWrap: false, viewState: null,
|
||||
});
|
||||
const b = store.promoteFromModal({
|
||||
sessionId: "conn_B",
|
||||
hostId: "host_2",
|
||||
remotePath: "/etc/hosts",
|
||||
fileName: "hosts",
|
||||
languageId: "plaintext",
|
||||
content: "", baselineContent: "", wordWrap: false, viewState: null,
|
||||
});
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(store.getTabs().length, 2);
|
||||
});
|
||||
|
||||
test("confirmCloseBySession returns true when no tabs match", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab());
|
||||
const ok = await store.confirmCloseBySession("other_conn", async () => "discard");
|
||||
assert.equal(ok, true);
|
||||
assert.equal(store.getTabs().length, 1);
|
||||
});
|
||||
|
||||
test("confirmCloseBySession discards all dirty matching tabs when prompt returns 'discard'", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1", content: "x", baselineContent: "y" }));
|
||||
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
|
||||
const ok = await store.confirmCloseBySession("conn_1", async () => "discard");
|
||||
assert.equal(ok, true);
|
||||
assert.equal(store.getTabs().length, 0);
|
||||
});
|
||||
|
||||
test("confirmCloseBySession closes clean tabs without prompting; aborts on cancel", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_clean" })); // content == baseline
|
||||
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
|
||||
let prompts = 0;
|
||||
const ok = await store.confirmCloseBySession("conn_1", async () => { prompts++; return "cancel"; });
|
||||
assert.equal(ok, false);
|
||||
assert.equal(prompts, 1, "prompt fires only for dirty tab");
|
||||
// clean tab was closed before the dirty cancel aborted the batch
|
||||
assert.equal(store.getTab("edt_clean"), undefined);
|
||||
assert.ok(store.getTab("edt_dirty"));
|
||||
});
|
||||
|
||||
test("confirmCloseBySession invokes save callback for 'save' choice and only closes on save success", async () => {
|
||||
const store = new EditorTabStore();
|
||||
store._debugInsert(makeTab({ id: "edt_1", content: "new", baselineContent: "old" }));
|
||||
let saved = false;
|
||||
const ok = await store.confirmCloseBySession("conn_1", async () => "save", async (id) => {
|
||||
assert.equal(id, "edt_1");
|
||||
saved = true;
|
||||
store.markSaved(id, "new");
|
||||
});
|
||||
assert.equal(saved, true);
|
||||
assert.equal(ok, true);
|
||||
assert.equal(store.getTab("edt_1"), undefined);
|
||||
});
|
||||
252
application/state/editorTabStore.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import type * as Monaco from "monaco-editor";
|
||||
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
|
||||
|
||||
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
|
||||
// may contain semantic ".." segments we don't want to resolve client-side).
|
||||
const normalizePath = (p: string): string => {
|
||||
const collapsed = p.replace(/\/+/g, "/").replace(/\/\.(?=\/|$)/g, "");
|
||||
return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
|
||||
};
|
||||
|
||||
export type EditorTabId = string;
|
||||
|
||||
export type EditorSavingState = "idle" | "saving" | "error";
|
||||
|
||||
export interface EditorTab {
|
||||
id: EditorTabId;
|
||||
kind: "editor";
|
||||
/** SFTP connection id (matches SftpConnection.id). Session lookup key. */
|
||||
sessionId: string;
|
||||
/** Stable endpoint id; used to verify the session is still the one we opened against. */
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
content: string;
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
savingState: EditorSavingState;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let idCounter = 0;
|
||||
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
|
||||
|
||||
export class EditorTabStore {
|
||||
private tabs: EditorTab[] = [];
|
||||
private listeners = new Set<Listener>();
|
||||
private pendingNotify = false;
|
||||
|
||||
getTabs = (): readonly EditorTab[] => this.tabs;
|
||||
getTab = (id: EditorTabId): EditorTab | undefined => this.tabs.find((t) => t.id === id);
|
||||
isDirty = (id: EditorTabId): boolean => {
|
||||
const t = this.getTab(id);
|
||||
return !!t && t.content !== t.baselineContent;
|
||||
};
|
||||
|
||||
updateContent = (
|
||||
id: EditorTabId,
|
||||
content: string,
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null,
|
||||
) => {
|
||||
this.patch(id, { content, viewState });
|
||||
};
|
||||
|
||||
markSaved = (id: EditorTabId, newBaseline: string) => {
|
||||
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
|
||||
};
|
||||
|
||||
setWordWrap = (id: EditorTabId, value: boolean) => {
|
||||
this.patch(id, { wordWrap: value });
|
||||
};
|
||||
|
||||
setLanguage = (id: EditorTabId, languageId: string) => {
|
||||
this.patch(id, { languageId });
|
||||
};
|
||||
|
||||
setSavingState = (id: EditorTabId, state: EditorSavingState, error: string | null = null) => {
|
||||
const patch: Partial<EditorTab> = { savingState: state };
|
||||
if (state === "idle") patch.saveError = null;
|
||||
else if (state === "error") patch.saveError = error;
|
||||
this.patch(id, patch);
|
||||
};
|
||||
|
||||
close = (id: EditorTabId) => {
|
||||
const next = this.tabs.filter((t) => t.id !== id);
|
||||
if (next.length !== this.tabs.length) {
|
||||
this.tabs = next;
|
||||
this.notify();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Force-close every tab bound to any of the given sessionIds, with no dirty
|
||||
* prompt. Intended for cases where the owning SFTP instance has gone away
|
||||
* entirely (e.g. the hosting terminal tab was closed) and there is no
|
||||
* realistic save channel anyway. Returns the closed tab ids.
|
||||
*/
|
||||
forceCloseBySessions = (sessionIds: readonly string[]): EditorTabId[] => {
|
||||
if (sessionIds.length === 0) return [];
|
||||
const idSet = new Set(sessionIds);
|
||||
const removed = this.tabs.filter((t) => idSet.has(t.sessionId)).map((t) => t.id);
|
||||
if (removed.length === 0) return [];
|
||||
this.tabs = this.tabs.filter((t) => !idSet.has(t.sessionId));
|
||||
this.notify();
|
||||
|
||||
// If the current active tab was one of the editor tabs we just removed,
|
||||
// fall back to 'vault' so the user doesn't end up on a stale id (empty
|
||||
// chrome + no content). Any better neighbor choice would need the full
|
||||
// orderedTabs list, which isn't available here; 'vault' is always valid.
|
||||
const activeId = activeTabStore.getActiveTabId();
|
||||
if (isEditorTabId(activeId)) {
|
||||
const activeEditorId = fromEditorTabId(activeId);
|
||||
if (activeEditorId && removed.includes(activeEditorId)) {
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
};
|
||||
|
||||
promoteFromModal = (snapshot: {
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
languageId: string;
|
||||
content: string;
|
||||
baselineContent: string;
|
||||
wordWrap: boolean;
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
}): EditorTabId => {
|
||||
const normalized = normalizePath(snapshot.remotePath);
|
||||
const existing = this.tabs.find(
|
||||
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
|
||||
);
|
||||
if (existing) {
|
||||
this.patch(existing.id, {
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
const tab: EditorTab = {
|
||||
id: this.makeId(),
|
||||
kind: "editor",
|
||||
sessionId: snapshot.sessionId,
|
||||
hostId: snapshot.hostId,
|
||||
remotePath: snapshot.remotePath,
|
||||
fileName: snapshot.fileName,
|
||||
languageId: snapshot.languageId,
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
savingState: "idle",
|
||||
saveError: null,
|
||||
};
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notify();
|
||||
return tab.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk all editor tabs bound to `sessionId`. Clean tabs close silently; dirty tabs
|
||||
* prompt via `promptChoice`. 'save' invokes `saveTab` and closes only on its success.
|
||||
* Any 'cancel' aborts the batch (subsequent dirty tabs are preserved) and returns false.
|
||||
*/
|
||||
confirmCloseBySession = async (
|
||||
sessionId: string,
|
||||
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
|
||||
saveTab?: (tabId: EditorTabId) => Promise<void>,
|
||||
): Promise<boolean> => {
|
||||
const matching = this.tabs.filter((t) => t.sessionId === sessionId);
|
||||
for (const tab of matching) {
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
this.close(tab.id);
|
||||
continue;
|
||||
}
|
||||
const choice = await promptChoice(tab);
|
||||
if (choice === "cancel") return false;
|
||||
if (choice === "discard") { this.close(tab.id); continue; }
|
||||
if (choice === "save") {
|
||||
if (!saveTab) throw new Error("saveTab callback required when 'save' choice is possible");
|
||||
try {
|
||||
await saveTab(tab.id);
|
||||
} catch {
|
||||
// Save failed — treat like cancel (keep tab open, abort batch so the user sees the error)
|
||||
return false;
|
||||
}
|
||||
this.close(tab.id);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
};
|
||||
|
||||
/** TEST-ONLY: seed a tab without going through promote/openOrFocus. */
|
||||
_debugInsert = (tab: EditorTab) => {
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.notify();
|
||||
};
|
||||
|
||||
protected makeId = genId;
|
||||
|
||||
protected patch = (id: EditorTabId, patch: Partial<EditorTab>) => {
|
||||
let changed = false;
|
||||
this.tabs = this.tabs.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
changed = true;
|
||||
return { ...t, ...patch };
|
||||
});
|
||||
if (changed) this.notify();
|
||||
};
|
||||
|
||||
protected notify = () => {
|
||||
if (this.pendingNotify) return;
|
||||
this.pendingNotify = true;
|
||||
Promise.resolve().then(() => {
|
||||
this.pendingNotify = false;
|
||||
this.listeners.forEach((l) => l());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const editorTabStore = new EditorTabStore();
|
||||
|
||||
// Hooks
|
||||
const getTabsSnapshot = () => editorTabStore.getTabs();
|
||||
|
||||
export const useEditorTabs = (): readonly EditorTab[] =>
|
||||
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot);
|
||||
|
||||
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useEditorDirty = (id: EditorTabId): boolean => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useAnyEditorDirty = (): boolean => {
|
||||
const getSnapshot = useCallback(
|
||||
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
|
||||
[],
|
||||
);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useRef, useMemo } from "react";
|
||||
import { TransferTask, TransferStatus } from "../../../domain/models";
|
||||
import { TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
@@ -20,6 +20,7 @@ export type { UploadResult };
|
||||
|
||||
interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
@@ -35,6 +36,13 @@ interface SftpExternalOperationsResult {
|
||||
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
|
||||
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
|
||||
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
|
||||
writeTextFileByConnection: (
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
) => Promise<void>;
|
||||
downloadToTempAndOpen: (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
@@ -62,6 +70,7 @@ export const useSftpExternalOperations = (
|
||||
): SftpExternalOperationsResult => {
|
||||
const {
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
@@ -173,6 +182,41 @@ export const useSftpExternalOperations = (
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const writeTextFileByConnection = useCallback(
|
||||
async (
|
||||
connectionId: string,
|
||||
expectedHostId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
filenameEncoding?: SftpFilenameEncoding,
|
||||
): Promise<void> => {
|
||||
const pane = getPaneByConnectionId(connectionId);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("SFTP connection is no longer available");
|
||||
}
|
||||
if (pane.connection.hostId !== expectedHostId) {
|
||||
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeLocalFile) throw new Error("Local file writing not supported");
|
||||
const data = new TextEncoder().encode(content);
|
||||
await bridge.writeLocalFile(filePath, data.buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) throw new Error("SFTP session not found");
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) throw new Error("Bridge not available");
|
||||
|
||||
await bridge.writeSftp(sftpId, filePath, content, filenameEncoding ?? pane.filenameEncoding);
|
||||
},
|
||||
[getPaneByConnectionId, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
@@ -693,6 +737,7 @@ export const useSftpExternalOperations = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
|
||||
@@ -301,6 +301,7 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
@@ -309,6 +310,7 @@ export const useSftpState = (
|
||||
activeFileWatchCountRef,
|
||||
} = useSftpExternalOperations({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
@@ -359,6 +361,7 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
@@ -413,6 +416,7 @@ export const useSftpState = (
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
writeTextFileByConnection,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
@@ -476,6 +480,8 @@ export const useSftpState = (
|
||||
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
|
||||
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
|
||||
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
|
||||
writeTextFileByConnection: (...args: Parameters<typeof writeTextFileByConnection>) =>
|
||||
methodsRef.current.writeTextFileByConnection(...args),
|
||||
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
||||
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
||||
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* ScriptsSidePanel - Lightweight scripts browser for the terminal side panel
|
||||
*
|
||||
* Shows snippets organized by package hierarchy with breadcrumb navigation.
|
||||
* Clicking a snippet executes it in the focused terminal session.
|
||||
* Shows snippets organized by package hierarchy as a single tree view.
|
||||
* Packages expand / collapse via a chevron; clicking a snippet executes it
|
||||
* in the focused terminal session. Typing in the search box flattens to a
|
||||
* list of matching snippets regardless of package nesting.
|
||||
*/
|
||||
|
||||
import { ChevronRight, Edit2, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Edit2, FileCode, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Snippet } from '../types';
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
} from './ui/context-menu';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
interface ScriptsSidePanelProps {
|
||||
snippets: Snippet[];
|
||||
@@ -26,6 +29,33 @@ interface ScriptsSidePanelProps {
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
type TreeRow =
|
||||
| {
|
||||
type: 'package';
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
count: number;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'snippet';
|
||||
id: string;
|
||||
depth: number;
|
||||
snippet: Snippet;
|
||||
packagePath: string;
|
||||
};
|
||||
|
||||
const pkgDisplayName = (path: string) => {
|
||||
const clean = path.startsWith('/') ? path.slice(1) : path;
|
||||
const last = clean.split('/').filter(Boolean).pop() ?? clean;
|
||||
// Preserve the leading slash on absolute root packages so they stay
|
||||
// distinguishable from relative ones (matches the previous breadcrumb UI).
|
||||
return path.startsWith('/') && !clean.includes('/') ? `/${last}` : last;
|
||||
};
|
||||
|
||||
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
@@ -33,97 +63,151 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
// Normalize the package list + derive ancestor packages implied by each path
|
||||
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
|
||||
const normalizedPackages = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
const addWithAncestors = (raw: string) => {
|
||||
const path = raw.trim();
|
||||
if (!path) return;
|
||||
const isAbs = path.startsWith('/');
|
||||
const body = isAbs ? path.slice(1) : path;
|
||||
const parts = body.split('/').filter(Boolean);
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
const sub = parts.slice(0, i).join('/');
|
||||
set.add(isAbs ? `/${sub}` : sub);
|
||||
}
|
||||
};
|
||||
packages.forEach(addWithAncestors);
|
||||
// A snippet may reference a package path that's not in `packages` yet.
|
||||
snippets.forEach((s) => {
|
||||
if (s.package) addWithAncestors(s.package);
|
||||
});
|
||||
return set;
|
||||
}, [packages, snippets]);
|
||||
|
||||
const results: { name: string; path: string; count: number }[] = [];
|
||||
// Track every package we've ever observed so we can tell "new" from
|
||||
// "previously-seen-but-user-collapsed". Without this, any unrelated refresh
|
||||
// that reduced prev.size (because the user collapsed a row) would
|
||||
// incorrectly trip a bulk re-expand.
|
||||
const seenPackagesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const relativeRoots = relativePaths
|
||||
.map((p) => p.split('/')[0])
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
// Default: auto-expand packages the first time they appear, so the user sees
|
||||
// everything without drilling in. After that, respect the user's collapse
|
||||
// choices across unrelated refreshes.
|
||||
useEffect(() => {
|
||||
const seen = seenPackagesRef.current;
|
||||
const newlySeen: string[] = [];
|
||||
normalizedPackages.forEach((p) => {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
newlySeen.push(p);
|
||||
}
|
||||
});
|
||||
if (newlySeen.length === 0) return;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
newlySeen.forEach((p) => next.add(p));
|
||||
return next;
|
||||
});
|
||||
}, [normalizedPackages]);
|
||||
|
||||
Array.from(new Set(relativeRoots)).forEach((name: string) => {
|
||||
const path: string = name;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name, path, count });
|
||||
});
|
||||
const togglePackage = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const absoluteRoots = absolutePaths
|
||||
.map((p) => {
|
||||
const cleanPath = p.substring(1);
|
||||
return cleanPath.split('/')[0];
|
||||
// When search is active, flatten everything (no tree, no packages).
|
||||
const searchMatches = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return snippets.filter(
|
||||
(s) =>
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
s.command.toLowerCase().includes(q),
|
||||
);
|
||||
}, [snippets, search]);
|
||||
|
||||
const rows = useMemo<TreeRow[]>(() => {
|
||||
if (searchMatches !== null) return [];
|
||||
|
||||
const out: TreeRow[] = [];
|
||||
const paths: string[] = [];
|
||||
normalizedPackages.forEach((p) => paths.push(p));
|
||||
|
||||
const childPackagesOf = (parent: string | null): string[] => {
|
||||
const prefix = parent === null ? '' : parent + '/';
|
||||
return paths
|
||||
.filter((p) => {
|
||||
if (parent === null) {
|
||||
// Root-level: no "/" inside the body
|
||||
const body = p.startsWith('/') ? p.slice(1) : p;
|
||||
return !body.includes('/');
|
||||
}
|
||||
if (!p.startsWith(prefix)) return false;
|
||||
const rest = p.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes('/');
|
||||
})
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
.sort((a, b) => pkgDisplayName(a).localeCompare(pkgDisplayName(b)));
|
||||
};
|
||||
|
||||
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
|
||||
const path: string = `/${name}`;
|
||||
const displayName: string = `/${name}`;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name: displayName, path, count });
|
||||
const snippetsIn = (pkg: string | null): Snippet[] =>
|
||||
snippets
|
||||
.filter((s) => (s.package || '') === (pkg ?? ''))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const countDescendants = (pkg: string): number =>
|
||||
snippets.filter((s) => {
|
||||
const sp = s.package || '';
|
||||
return sp === pkg || sp.startsWith(pkg + '/');
|
||||
}).length;
|
||||
|
||||
const walk = (pkg: string, depth: number) => {
|
||||
const children = childPackagesOf(pkg);
|
||||
const localSnippets = snippetsIn(pkg);
|
||||
const hasChildren = children.length > 0 || localSnippets.length > 0;
|
||||
const isExpanded = expandedPaths.has(pkg);
|
||||
|
||||
out.push({
|
||||
type: 'package',
|
||||
id: pkg,
|
||||
path: pkg,
|
||||
name: pkgDisplayName(pkg),
|
||||
depth,
|
||||
count: countDescendants(pkg),
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const prefix = selectedPackage + '/';
|
||||
const children = packages
|
||||
.filter((p) => p.startsWith(prefix))
|
||||
.map((p) => p.replace(prefix, '').split('/')[0])
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
return Array.from(new Set(children)).map((name) => {
|
||||
const path = `${selectedPackage}/${name}`;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
|
||||
const displayedSnippets = useMemo(() => {
|
||||
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(sn =>
|
||||
sn.label.toLowerCase().includes(s) ||
|
||||
sn.command.toLowerCase().includes(s)
|
||||
if (!isExpanded) return;
|
||||
children.forEach((c) => walk(c, depth + 1));
|
||||
localSnippets.forEach((s) =>
|
||||
out.push({ type: 'snippet', id: s.id, depth: depth + 1, snippet: s, packagePath: pkg }),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [snippets, selectedPackage, search]);
|
||||
};
|
||||
|
||||
// Also filter packages by search when at root level
|
||||
const filteredPackages = useMemo(() => {
|
||||
if (!search.trim()) return displayedPackages;
|
||||
const s = search.toLowerCase();
|
||||
return displayedPackages.filter(pkg => pkg.name.toLowerCase().includes(s));
|
||||
}, [displayedPackages, search]);
|
||||
// Orphan / uncategorized snippets first (package === '')
|
||||
snippetsIn(null).forEach((s) =>
|
||||
out.push({ type: 'snippet', id: s.id, depth: 0, snippet: s, packagePath: '' }),
|
||||
);
|
||||
childPackagesOf(null).forEach((root) => walk(root, 0));
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
const parts = selectedPackage.split('/').filter(Boolean);
|
||||
return parts.map((name, idx) => {
|
||||
const pathSegments = parts.slice(0, idx + 1);
|
||||
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
|
||||
return { name, path };
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
return out;
|
||||
}, [normalizedPackages, snippets, expandedPaths, searchMatches]);
|
||||
|
||||
const handleSnippetClick = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
onSnippetClick(command, noAutoRun);
|
||||
}, [onSnippetClick]);
|
||||
const handleSnippetClick = useCallback(
|
||||
(command: string, noAutoRun?: boolean) => {
|
||||
onSnippetClick(command, noAutoRun);
|
||||
},
|
||||
[onSnippetClick],
|
||||
);
|
||||
|
||||
const handleAddSnippet = useCallback(() => {
|
||||
// Let the App shell listen and navigate to the Snippets section with
|
||||
@@ -149,6 +233,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const hasAnyContent = snippets.length > 0 || packages.length > 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
data-section="snippets-panel"
|
||||
@@ -175,30 +260,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="shrink-0 flex items-center gap-1 px-3 py-1.5 text-[11px] border-b border-border/30 min-h-[28px]">
|
||||
<button
|
||||
className={cn(
|
||||
"hover:text-primary transition-colors truncate",
|
||||
!selectedPackage ? "text-foreground font-medium" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setSelectedPackage(null)}
|
||||
>
|
||||
{t('terminal.toolbar.library')}
|
||||
</button>
|
||||
{breadcrumb.map((b) => (
|
||||
<React.Fragment key={b.path}>
|
||||
<ChevronRight size={10} className="text-muted-foreground shrink-0" />
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary transition-colors truncate"
|
||||
onClick={() => setSelectedPackage(b.path)}
|
||||
>
|
||||
{b.name}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-1">
|
||||
@@ -209,55 +270,47 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{filteredPackages.map((pkg) => (
|
||||
<button
|
||||
key={pkg.path}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => { setSelectedPackage(pkg.path); setSearch(''); }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
<Package size={12} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{pkg.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('snippets.package.count', { count: pkg.count })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={12} className="text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
{/* Search flat list */}
|
||||
{searchMatches !== null && searchMatches.length > 0 &&
|
||||
searchMatches.map((s) => (
|
||||
<SnippetRow
|
||||
key={s.id}
|
||||
snippet={s}
|
||||
depth={0}
|
||||
subtitle={s.package || t('terminal.toolbar.library')}
|
||||
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
|
||||
onEdit={() => handleEditSnippet(s)}
|
||||
onDelete={() => handleDeleteSnippet(s.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Snippets */}
|
||||
{displayedSnippets.map((s) => (
|
||||
<ContextMenu key={s.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{s.label}</span>
|
||||
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
|
||||
{s.command}
|
||||
</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleEditSnippet(s)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteSnippet(s.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
{/* Tree */}
|
||||
{searchMatches === null &&
|
||||
rows.map((row) =>
|
||||
row.type === 'package' ? (
|
||||
<PackageRow
|
||||
key={`pkg:${row.id}`}
|
||||
row={row}
|
||||
countLabel={t('snippets.package.count', { count: row.count })}
|
||||
onToggle={() => togglePackage(row.path)}
|
||||
/>
|
||||
) : (
|
||||
<SnippetRow
|
||||
key={`snip:${row.id}`}
|
||||
snippet={row.snippet}
|
||||
depth={row.depth}
|
||||
onClick={() => handleSnippetClick(row.snippet.command, row.snippet.noAutoRun)}
|
||||
onEdit={() => handleEditSnippet(row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(row.snippet.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{hasAnyContent && displayedSnippets.length === 0 && filteredPackages.length === 0 && search.trim() && (
|
||||
{hasAnyContent && searchMatches !== null && searchMatches.length === 0 && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
@@ -265,8 +318,100 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
interface PackageRowProps {
|
||||
row: Extract<TreeRow, { type: 'package' }>;
|
||||
countLabel: string;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors"
|
||||
style={{ paddingLeft: 8 + row.depth * 14 }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground transition-transform',
|
||||
row.isExpanded && 'rotate-90',
|
||||
!row.hasChildren && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Package size={12} className="shrink-0 text-primary/80" />
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-medium">{row.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">{countLabel}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
interface SnippetRowProps {
|
||||
snippet: Snippet;
|
||||
depth: number;
|
||||
subtitle?: string;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
editLabel: string;
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
const SnippetRow: React.FC<SnippetRowProps> = ({
|
||||
snippet,
|
||||
depth,
|
||||
subtitle,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
editLabel,
|
||||
deleteLabel,
|
||||
}) => (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden"
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
{/* Hidden chevron column mirrors PackageRow's layout so the
|
||||
snippet icon lines up exactly with the package icon above. */}
|
||||
<ChevronRight size={12} className="shrink-0 opacity-0" aria-hidden />
|
||||
<FileCode size={12} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-medium">{snippet.label}</span>
|
||||
{subtitle && (
|
||||
<span className="shrink-0 max-w-[40%] truncate text-[10px] text-muted-foreground">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="start" className="max-w-[480px]">
|
||||
<div className="font-medium text-xs mb-1 break-all">{snippet.label}</div>
|
||||
<pre className="font-mono text-[11px] whitespace-pre-wrap break-all leading-snug opacity-90">
|
||||
{snippet.command}
|
||||
</pre>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
|
||||
@@ -14,6 +14,8 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { editorTabStore } from "../application/state/editorTabStore";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { getParentPath } from "../application/state/sftp/utils";
|
||||
@@ -125,6 +127,46 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
// Register this instance's writeTextFileByConnection with the editor bridge
|
||||
// so editor tabs promoted from SFTP files opened in a terminal side panel
|
||||
// can still route saves through this useSftpState.
|
||||
//
|
||||
// Intentionally no deps — go through sftpRef so SFTP state churn (transfers,
|
||||
// tab switches, listings) doesn't make this unregister+reregister on every
|
||||
// re-render.
|
||||
useEffect(() => {
|
||||
return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding) =>
|
||||
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// When this side panel unmounts (its hosting terminal tab was closed) we
|
||||
// force-close any editor tabs bound to connections this panel owned — the
|
||||
// save channel is gone with the SFTP session and there's no way to recover
|
||||
// it. Dirty state is dropped intentionally; the user closed the terminal
|
||||
// knowing the file was open.
|
||||
//
|
||||
// Collect every connection id across all left/right tabs — the panel can
|
||||
// host multiple SFTP tabs per side, and an editor tab promoted from an
|
||||
// inactive-pane tab would otherwise be stranded by the unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const s = sftpRef.current;
|
||||
if (!s) return;
|
||||
const owned = new Set<string>();
|
||||
for (const tab of s.leftTabs?.tabs ?? []) {
|
||||
const id = tab.connection?.id;
|
||||
if (id) owned.add(id);
|
||||
}
|
||||
for (const tab of s.rightTabs?.tabs ?? []) {
|
||||
const id = tab.connection?.id;
|
||||
if (id) owned.add(id);
|
||||
}
|
||||
if (owned.size === 0) return;
|
||||
editorTabStore.forceCloseBySessions([...owned]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
|
||||
@@ -224,6 +266,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -679,6 +722,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
@@ -27,6 +27,7 @@ import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
// Import extracted components
|
||||
@@ -135,6 +136,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
// Register this useSftpState's writeTextFileByConnection with the bridge so
|
||||
// the editor tab's save path can reach the active SFTP session. The bridge
|
||||
// supports multiple simultaneous writers (SftpSidePanel inside terminals
|
||||
// also registers its own instance) and dispatches by trying each until one
|
||||
// owns the target connectionId.
|
||||
//
|
||||
// Intentionally no deps: `sftp` identity churns on every SFTP state change
|
||||
// (transfers, pane updates, tab switches), which would make this effect
|
||||
// unregister+reregister constantly. Route through sftpRef so the closure
|
||||
// always reads the latest writeTextFileByConnection; that method is stable
|
||||
// across sftp re-renders (it's a methodsRef-backed dispatcher).
|
||||
useEffect(() => {
|
||||
return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding) =>
|
||||
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Store behavior setting in ref for stable callbacks
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
@@ -219,6 +237,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -475,6 +494,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -374,6 +374,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
const terminalEncodingRef = useRef(terminalEncoding);
|
||||
terminalEncodingRef.current = terminalEncoding;
|
||||
// True only after the user actively picks an encoding from the toolbar.
|
||||
// onSessionAttached uses this to decide whether to override the backend's
|
||||
// initial charset for telnet/serial reconnects — on a first attach we
|
||||
// must not overwrite arbitrary host.charset values (latin1/shift_jis/...)
|
||||
// that the UI's two-value state can't represent.
|
||||
const userPickedEncodingRef = useRef(false);
|
||||
|
||||
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
||||
const {
|
||||
@@ -740,10 +746,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
// Sync terminal encoding to SSH backend before first data arrives
|
||||
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
|
||||
// SSH: always sync. Its backend starts in utf-8 regardless of
|
||||
// host.charset, so the push is what keeps the UI state aligned
|
||||
// across reconnects — including localhost SSH targets, hence
|
||||
// hostname isn't in the gate.
|
||||
const isLocal = host.protocol === 'local' || host.id?.startsWith('local-');
|
||||
const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-');
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const isMosh = host.protocol === 'mosh' || host.moshEnabled;
|
||||
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh;
|
||||
if (isSSH) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
return;
|
||||
}
|
||||
// Telnet / serial: the backend already applied host.charset
|
||||
// (including arbitrary iconv labels like latin1 / shift_jis that
|
||||
// the UI's two-value state can't represent) through start*Session
|
||||
// options, so don't clobber it on first attach. Only re-sync once
|
||||
// the user has explicitly picked from the toolbar menu — that's
|
||||
// the signal they want the UI choice to win on reconnect.
|
||||
if ((isTelnet || isSerial) && userPickedEncodingRef.current) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
@@ -1387,6 +1410,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
setTerminalEncoding(encoding);
|
||||
userPickedEncodingRef.current = true;
|
||||
if (sessionRef.current) {
|
||||
setSessionEncoding(sessionRef.current, encoding);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
/**
|
||||
* TextEditorModal - Modal for editing text files in SFTP with syntax highlighting
|
||||
* TextEditorModal - Dialog shell for editing text files in SFTP.
|
||||
* Delegates all editor chrome to TextEditorPane.
|
||||
*/
|
||||
import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Search,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Configure Monaco to use local files instead of CDN
|
||||
const monacoBasePath = import.meta.env.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${import.meta.env.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../application/state/useClipboardBackend';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../domain/models';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import { getLanguageId } from '../lib/sftpFileUtils';
|
||||
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
|
||||
import { toast } from './ui/toast';
|
||||
import { TextEditorPane } from './editor/TextEditorPane';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { HotkeyScheme, KeyBinding } from '../domain/models';
|
||||
|
||||
/** Snapshot passed to `onPromoteToTab` when the user clicks the maximize button. */
|
||||
export interface TextEditorModalSnapshot {
|
||||
/** The file name at the time of promotion (modal's fileName prop). */
|
||||
fileName: string;
|
||||
/** The clean baseline content at the time the modal was opened. */
|
||||
baselineContent: string;
|
||||
/** The current (possibly-dirty) editor content. */
|
||||
content: string;
|
||||
/** The current language ID selected by the user (may differ from file-detected default). */
|
||||
languageId: string;
|
||||
/** The current word-wrap state (carried over so the tab opens with the same setting). */
|
||||
wordWrap: boolean;
|
||||
/** The latest Monaco view state (scroll position, cursor, etc.) — may be null before first edit. */
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null;
|
||||
}
|
||||
|
||||
interface TextEditorModalProps {
|
||||
open: boolean;
|
||||
@@ -37,128 +38,10 @@ interface TextEditorModalProps {
|
||||
onToggleWordWrap: () => void;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** If provided, a maximize button is shown in the Pane header. */
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
'javascript': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
'python': 'python',
|
||||
'shell': 'shell',
|
||||
'batch': 'bat',
|
||||
'powershell': 'powershell',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'java': 'java',
|
||||
'kotlin': 'kotlin',
|
||||
'go': 'go',
|
||||
'rust': 'rust',
|
||||
'ruby': 'ruby',
|
||||
'php': 'php',
|
||||
'perl': 'perl',
|
||||
'lua': 'lua',
|
||||
'r': 'r',
|
||||
'swift': 'swift',
|
||||
'dart': 'dart',
|
||||
'csharp': 'csharp',
|
||||
'fsharp': 'fsharp',
|
||||
'vb': 'vb',
|
||||
'html': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
'json5': 'json',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'toml': 'ini',
|
||||
'ini': 'ini',
|
||||
'sql': 'sql',
|
||||
'graphql': 'graphql',
|
||||
'markdown': 'markdown',
|
||||
'plaintext': 'plaintext',
|
||||
'vue': 'html',
|
||||
'svelte': 'html',
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'diff': 'diff',
|
||||
};
|
||||
return mapping[langId] || 'plaintext';
|
||||
};
|
||||
|
||||
// Convert HSL string "h s% l%" to hex color
|
||||
const hslToHex = (hslString: string): string => {
|
||||
const parts = hslString.trim().split(/\s+/);
|
||||
if (parts.length < 3) return '#1e1e1e';
|
||||
const h = parseFloat(parts[0]) / 360;
|
||||
const s = parseFloat(parts[1].replace('%', '')) / 100;
|
||||
const l = parseFloat(parts[2].replace('%', '')) / 100;
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
const toHex = (x: number) => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// Read a CSS custom-property and convert from HSL to hex
|
||||
const getCssColor = (varName: string, fallback: string): string => {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
return value ? hslToHex(value) : fallback;
|
||||
};
|
||||
|
||||
interface EditorColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
primary: string;
|
||||
card: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
/** Read all UI CSS variables that matter for the Monaco theme. */
|
||||
const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
|
||||
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
|
||||
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
|
||||
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
|
||||
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
|
||||
const getThemeSignal = (): string => {
|
||||
const root = document.documentElement;
|
||||
return root.dataset.immersiveTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
};
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -169,182 +52,45 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onToggleWordWrap,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onPromoteToTab,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const monaco = useMonaco();
|
||||
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
// Latest view state captured from Pane's onContentChange — used by handlePromote
|
||||
const viewStateRef = useRef<Monaco.editor.ICodeEditorViewState | null>(null);
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
// Derived: whether the current content differs from the clean baseline
|
||||
const hasChanges = content !== initialContent;
|
||||
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
const colors = getEditorColors(isDarkTheme);
|
||||
|
||||
const themeColors: Record<string, string> = {
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
'editorCursor.foreground': colors.primary,
|
||||
'editor.selectionBackground': colors.primary + '40',
|
||||
'editor.inactiveSelectionBackground': colors.primary + '25',
|
||||
'editorLineNumber.foreground': colors.mutedFg,
|
||||
'editorLineNumber.activeForeground': colors.fg,
|
||||
'editor.lineHighlightBackground': colors.fg + '08',
|
||||
'editorWidget.background': colors.card,
|
||||
'editorWidget.foreground': colors.fg,
|
||||
'editorWidget.border': colors.border,
|
||||
'input.background': colors.card,
|
||||
'input.foreground': colors.fg,
|
||||
'input.border': colors.border,
|
||||
};
|
||||
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
setThemeSignal(getThemeSignal());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-immersive-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Reset content when file changes
|
||||
// Reset all state when a new file is opened
|
||||
useEffect(() => {
|
||||
setContent(initialContent);
|
||||
setHasChanges(false);
|
||||
setSaveError(null);
|
||||
setLanguageId(getLanguageId(fileName));
|
||||
viewStateRef.current = null;
|
||||
}, [initialContent, fileName]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== initialContent);
|
||||
}, [content, initialContent]);
|
||||
|
||||
const closeTabBinding = useMemo(
|
||||
() => keyBindings.find((binding) => binding.action === 'closeTab'),
|
||||
[keyBindings],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await onSave(content);
|
||||
setHasChanges(false);
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t('sftp.editor.saveFailed'),
|
||||
'SFTP'
|
||||
);
|
||||
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
|
||||
setSaveError(msg);
|
||||
toast.error(msg, 'SFTP');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, onSave, saving, t]);
|
||||
|
||||
// Keep the ref updated with the latest handleSave function
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
|
||||
try {
|
||||
return await readClipboardTextFromBridge();
|
||||
} catch {
|
||||
// Both clipboard APIs unavailable; signal failure so caller can fall back.
|
||||
return null;
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
// Match Monaco's default multicursorPaste:'spread' behavior:
|
||||
// distribute one line per cursor when line count equals cursor count.
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
editor.executeEdits(
|
||||
'netcatty-paste',
|
||||
selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i] : text,
|
||||
forceMoveMarkers: true,
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
|
||||
@@ -353,222 +99,53 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onClose();
|
||||
}, [hasChanges, onClose, t]);
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
setContent(value || '');
|
||||
}, []);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// Add save shortcut - use ref to avoid stale closure
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSaveRef.current();
|
||||
});
|
||||
|
||||
// Add find shortcut (Ctrl+F / Cmd+F)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [open]);
|
||||
|
||||
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (hotkeyScheme === 'disabled' || !closeTabBinding) return;
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopPropagation();
|
||||
handleClose();
|
||||
}, [closeTabBinding, handleClose, hotkeyScheme]);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('keyboard', 'actions.find', null);
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
|
||||
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
|
||||
const languageOptions = useMemo(
|
||||
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
|
||||
[supportedLanguages],
|
||||
const handleContentChange = useCallback(
|
||||
(nextContent: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
|
||||
setContent(nextContent);
|
||||
viewStateRef.current = viewState;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback((nextValue: string) => {
|
||||
setLanguageId(nextValue || 'plaintext');
|
||||
}, []);
|
||||
const handlePromote = useCallback(() => {
|
||||
if (!onPromoteToTab) return;
|
||||
onPromoteToTab({
|
||||
fileName,
|
||||
baselineContent: initialContent,
|
||||
content,
|
||||
languageId,
|
||||
wordWrap: editorWordWrap,
|
||||
viewState: viewStateRef.current,
|
||||
});
|
||||
}, [onPromoteToTab, fileName, initialContent, content, languageId, editorWordWrap]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent
|
||||
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0"
|
||||
hideCloseButton
|
||||
data-hotkey-close-tab="true"
|
||||
onKeyDownCapture={handleDialogKeyDownCapture}
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold truncate">
|
||||
{fileName}
|
||||
{hasChanges && <span className="text-primary ml-1">*</span>}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
title={t('common.search')}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={editorWordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
value={languageId}
|
||||
onValueChange={handleLanguageChange}
|
||||
placeholder={t('sftp.editor.syntaxHighlight')}
|
||||
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload size={14} className="mr-1.5" />
|
||||
)}
|
||||
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: editorWordWrap ? 'on' : 'off',
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{getLanguageName(languageId)}
|
||||
</span>
|
||||
<span>
|
||||
{content.split('\n').length} lines • {content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
{/* Radix requires a DialogTitle inside every DialogContent for a11y.
|
||||
The Pane's own header already shows the filename visually, so we
|
||||
mirror it here inside an sr-only DialogTitle for screen readers. */}
|
||||
<DialogTitle className="sr-only">{fileName}</DialogTitle>
|
||||
<TextEditorPane
|
||||
chrome="modal"
|
||||
fileName={`${fileName}${hasChanges ? ' *' : ''}`}
|
||||
content={content}
|
||||
languageId={languageId}
|
||||
wordWrap={editorWordWrap}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={setLanguageId}
|
||||
onToggleWordWrap={onToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
onRequestClose={handleClose}
|
||||
onPromoteToTab={onPromoteToTab ? handlePromote : undefined}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Bell, Copy, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import { Bell, Copy, FileCode, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import type { EditorTab } from '../application/state/editorTabStore';
|
||||
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
||||
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
||||
import { LogView } from '../application/state/useSessionState';
|
||||
@@ -19,6 +20,9 @@ import { SyncStatusButton } from './SyncStatusButton';
|
||||
const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
|
||||
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
|
||||
|
||||
// File extensions that render the code-file icon instead of the plain text icon.
|
||||
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
|
||||
|
||||
interface TopTabsProps {
|
||||
theme: 'dark' | 'light';
|
||||
followAppTerminalTheme?: boolean;
|
||||
@@ -46,6 +50,9 @@ interface TopTabsProps {
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
showSftpTab: boolean;
|
||||
editorTabs: readonly EditorTab[];
|
||||
onRequestCloseEditorTab: (editorTabId: string) => void;
|
||||
hostById: Map<string, Host>;
|
||||
}
|
||||
|
||||
// Detect local OS for local terminal tab icons
|
||||
@@ -255,6 +262,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
showSftpTab,
|
||||
editorTabs,
|
||||
onRequestCloseEditorTab,
|
||||
hostById,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
@@ -477,9 +487,30 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return styles;
|
||||
}, [dropIndicator, isDraggingForReorder, orderedTabs]);
|
||||
|
||||
// Pre-compute editor tab map for O(1) access
|
||||
const editorTabMap = useMemo(() => {
|
||||
const map = new Map<string, EditorTab>();
|
||||
for (const t of editorTabs) map.set(t.id, t);
|
||||
return map;
|
||||
}, [editorTabs]);
|
||||
|
||||
// fileName → count, for the rename-disambiguation suffix in the render loop.
|
||||
// Memoed so we don't do a per-tab O(n) filter on every render (was O(n²)).
|
||||
const editorTabFileNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const t of editorTabs) counts.set(t.fileName, (counts.get(t.fileName) ?? 0) + 1);
|
||||
return counts;
|
||||
}, [editorTabs]);
|
||||
|
||||
// Build ordered tab items using pre-computed maps for O(1) lookups
|
||||
const orderedTabItems = useMemo(() => {
|
||||
return orderedTabs.map((tabId) => {
|
||||
if (isEditorTabId(tabId)) {
|
||||
const editorId = fromEditorTabId(tabId);
|
||||
const editorTab = editorTabMap.get(editorId);
|
||||
if (!editorTab) return null;
|
||||
return { type: 'editor' as const, id: tabId, editorTab };
|
||||
}
|
||||
const session = orphanSessionMap.get(tabId);
|
||||
const workspace = workspaceMap.get(tabId);
|
||||
const logView = logViewMap.get(tabId);
|
||||
@@ -494,7 +525,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
}, [orderedTabs, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
|
||||
}, [orderedTabs, editorTabMap, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
|
||||
|
||||
// Bulk-close menu items shared by session and workspace context menus.
|
||||
// Anchor is the tab the user right-clicked on (matches VSCode/JetBrains UX).
|
||||
@@ -532,6 +563,77 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return orderedTabItems.map((item) => {
|
||||
if (!item) return null;
|
||||
|
||||
if (item.type === 'editor') {
|
||||
const { editorTab } = item;
|
||||
const tabId = item.id;
|
||||
const isActive = activeTabId === tabId;
|
||||
const host = hostById.get(editorTab.hostId);
|
||||
const dirty = editorTab.content !== editorTab.baselineContent;
|
||||
const tooltip = `${host?.label ?? editorTab.hostId}@${host?.hostname ?? ''}:${editorTab.remotePath}`;
|
||||
// Disambiguate duplicate filenames using the memoed counts map.
|
||||
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
|
||||
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
|
||||
: '';
|
||||
const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(tabId)}
|
||||
title={tooltip}
|
||||
className={cn(
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileIcon
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<span className="truncate flex items-center gap-0.5">
|
||||
{dirty && <span className="text-primary mr-0.5">●</span>}
|
||||
{editorTab.fileName}
|
||||
{suffix && <span className="text-muted-foreground ml-1">{suffix}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestCloseEditorTab(editorTab.id);
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
aria-label="Close editor tab"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'session') {
|
||||
const session = item.session;
|
||||
const hasActivity = !!sessionActivityMap[session.id];
|
||||
|
||||
584
components/editor/TextEditorPane.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* TextEditorPane — pure Monaco editor body + toolbar.
|
||||
* Extracted from TextEditorModal.tsx. Contains no Dialog shell.
|
||||
* Parents (modal or tab) own content state, saving state, and toast calls.
|
||||
*/
|
||||
import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Search,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Configure Monaco to use local files instead of CDN
|
||||
const monacoBasePath = import.meta.env.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${import.meta.env.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
'javascript': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
'python': 'python',
|
||||
'shell': 'shell',
|
||||
'batch': 'bat',
|
||||
'powershell': 'powershell',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'java': 'java',
|
||||
'kotlin': 'kotlin',
|
||||
'go': 'go',
|
||||
'rust': 'rust',
|
||||
'ruby': 'ruby',
|
||||
'php': 'php',
|
||||
'perl': 'perl',
|
||||
'lua': 'lua',
|
||||
'r': 'r',
|
||||
'swift': 'swift',
|
||||
'dart': 'dart',
|
||||
'csharp': 'csharp',
|
||||
'fsharp': 'fsharp',
|
||||
'vb': 'vb',
|
||||
'html': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
'json5': 'json',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'toml': 'ini',
|
||||
'ini': 'ini',
|
||||
'sql': 'sql',
|
||||
'graphql': 'graphql',
|
||||
'markdown': 'markdown',
|
||||
'plaintext': 'plaintext',
|
||||
'vue': 'html',
|
||||
'svelte': 'html',
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'diff': 'diff',
|
||||
};
|
||||
return mapping[langId] || 'plaintext';
|
||||
};
|
||||
|
||||
// Convert HSL string "h s% l%" to hex color
|
||||
const hslToHex = (hslString: string): string => {
|
||||
const parts = hslString.trim().split(/\s+/);
|
||||
if (parts.length < 3) return '#1e1e1e';
|
||||
const h = parseFloat(parts[0]) / 360;
|
||||
const s = parseFloat(parts[1].replace('%', '')) / 100;
|
||||
const l = parseFloat(parts[2].replace('%', '')) / 100;
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
const toHex = (x: number) => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// Read a CSS custom-property and convert from HSL to hex
|
||||
const getCssColor = (varName: string, fallback: string): string => {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
return value ? hslToHex(value) : fallback;
|
||||
};
|
||||
|
||||
interface EditorColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
primary: string;
|
||||
card: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
/** Read all UI CSS variables that matter for the Monaco theme. */
|
||||
const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
|
||||
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
|
||||
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
|
||||
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
|
||||
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
|
||||
const getThemeSignal = (): string => {
|
||||
const root = document.documentElement;
|
||||
return root.dataset.immersiveTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
};
|
||||
|
||||
export interface TextEditorPaneProps {
|
||||
fileName: string;
|
||||
content: string;
|
||||
languageId: string;
|
||||
wordWrap: boolean;
|
||||
saving: boolean;
|
||||
saveError: string | null;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** Layout mode — affects header chrome (modal shows close+maximize; tab-form only shows content controls since tab has its own close). */
|
||||
chrome: 'modal' | 'tab';
|
||||
/** Optional secondary label shown next to the filename in muted text — used by the tab form to display `host:remotePath`. */
|
||||
subtitle?: string;
|
||||
onContentChange: (content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => void;
|
||||
onLanguageChange: (nextLanguageId: string) => void;
|
||||
onToggleWordWrap: () => void;
|
||||
onSave: () => void;
|
||||
onRequestClose?: () => void; // modal only
|
||||
onPromoteToTab?: () => void; // modal only — omit to hide the maximize button
|
||||
initialViewState?: Monaco.editor.ICodeEditorViewState | null;
|
||||
}
|
||||
|
||||
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
fileName,
|
||||
content,
|
||||
languageId,
|
||||
wordWrap,
|
||||
saving,
|
||||
saveError,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
chrome,
|
||||
subtitle,
|
||||
onContentChange,
|
||||
onLanguageChange,
|
||||
onToggleWordWrap,
|
||||
onSave,
|
||||
onRequestClose,
|
||||
onPromoteToTab,
|
||||
initialViewState,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const monaco = useMonaco();
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => void>(() => {});
|
||||
const handleCloseRef = useRef<(() => void) | null>(null);
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
const colors = getEditorColors(isDarkTheme);
|
||||
|
||||
const themeColors: Record<string, string> = {
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
'editorCursor.foreground': colors.primary,
|
||||
'editor.selectionBackground': colors.primary + '40',
|
||||
'editor.inactiveSelectionBackground': colors.primary + '25',
|
||||
'editorLineNumber.foreground': colors.mutedFg,
|
||||
'editorLineNumber.activeForeground': colors.fg,
|
||||
'editor.lineHighlightBackground': colors.fg + '08',
|
||||
'editorWidget.background': colors.card,
|
||||
'editorWidget.foreground': colors.fg,
|
||||
'editorWidget.border': colors.border,
|
||||
'input.background': colors.card,
|
||||
'input.foreground': colors.fg,
|
||||
'input.border': colors.border,
|
||||
};
|
||||
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
setThemeSignal(getThemeSignal());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-immersive-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const closeTabBinding = useMemo(
|
||||
() => keyBindings.find((binding) => binding.action === 'closeTab'),
|
||||
[keyBindings],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (saving) return;
|
||||
onSave();
|
||||
}, [saving, onSave]);
|
||||
|
||||
// Keep the ref updated with the latest handleSave function
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
// Keep the close ref fresh so the Monaco Cmd/Ctrl+W command invokes the
|
||||
// latest onRequestClose handler without re-binding the Monaco command.
|
||||
useEffect(() => {
|
||||
handleCloseRef.current = onRequestClose ?? null;
|
||||
}, [onRequestClose]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
|
||||
try {
|
||||
return await readClipboardTextFromBridge();
|
||||
} catch {
|
||||
// Both clipboard APIs unavailable; signal failure so caller can fall back.
|
||||
return null;
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
// Match Monaco's default multicursorPaste:'spread' behavior:
|
||||
// distribute one line per cursor when line count equals cursor count.
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
editor.executeEdits(
|
||||
'netcatty-paste',
|
||||
selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i] : text,
|
||||
forceMoveMarkers: true,
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
const editor = editorRef.current;
|
||||
onContentChange(value ?? '', editor ? editor.saveViewState() : null);
|
||||
}, [onContentChange]);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
if (initialViewState) editor.restoreViewState(initialViewState);
|
||||
|
||||
// Add save shortcut - use ref to avoid stale closure
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSaveRef.current();
|
||||
});
|
||||
|
||||
// Close-tab shortcut inside Monaco. The capture-phase keydown on the
|
||||
// Pane's root div also tries to handle this, but Monaco's internal
|
||||
// key-event dispatcher fires first for focused editor keystrokes, so
|
||||
// registering the command here is the reliable path.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
|
||||
handleCloseRef.current?.();
|
||||
});
|
||||
|
||||
// Add find shortcut (Ctrl+F / Cmd+F)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
}, [initialViewState]);
|
||||
|
||||
// Capture-phase close-tab hotkey handler. Runs in both modal and tab chrome
|
||||
// so Cmd/Ctrl+W works even when focus is inside Monaco (which otherwise
|
||||
// swallows the event). Requires an `onRequestClose` prop from the parent.
|
||||
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (hotkeyScheme === 'disabled' || !closeTabBinding || !onRequestClose) return;
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopPropagation();
|
||||
onRequestClose();
|
||||
}, [closeTabBinding, hotkeyScheme, onRequestClose]);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('keyboard', 'actions.find', null);
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
|
||||
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
|
||||
const languageOptions = useMemo(
|
||||
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
|
||||
[supportedLanguages],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex flex-col"
|
||||
onKeyDownCapture={handleDialogKeyDownCapture}
|
||||
data-hotkey-close-tab={chrome === 'modal' ? 'true' : undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-baseline gap-2 flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate flex-shrink-0">
|
||||
{fileName}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span className="text-xs text-muted-foreground truncate" title={subtitle}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
{saveError && <span className="text-xs text-destructive truncate">{saveError}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
title={t('common.search')}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={wordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
value={languageId}
|
||||
onValueChange={(v) => onLanguageChange(v || 'plaintext')}
|
||||
placeholder={t('sftp.editor.syntaxHighlight')}
|
||||
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload size={14} className="mr-1.5" />
|
||||
)}
|
||||
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
|
||||
</Button>
|
||||
|
||||
{/* Maximize button — modal chrome only, when onPromoteToTab is provided */}
|
||||
{chrome === 'modal' && onPromoteToTab && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onPromoteToTab}
|
||||
title={t('sftp.editor.maximize')}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Close button — modal chrome only */}
|
||||
{chrome === 'modal' && onRequestClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRequestClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: wordWrap ? 'on' : 'off',
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{getLanguageName(languageId)}
|
||||
</span>
|
||||
<span>
|
||||
{content.split('\n').length} lines • {content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextEditorPane;
|
||||
128
components/editor/TextEditorTabView.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* TextEditorTabView — thin wrapper that binds an editorTab entry to TextEditorPane.
|
||||
*
|
||||
* Each tab has its own instance (keyed by tabId), so Monaco is never torn down
|
||||
* on tab-switch — we just toggle CSS visibility via the `isVisible` prop.
|
||||
*/
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { editorSftpWrite } from '../../application/state/editorSftpBridge';
|
||||
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
|
||||
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
|
||||
import type { Host } from '../../types';
|
||||
import { toast } from '../ui/toast';
|
||||
import { TextEditorPane } from './TextEditorPane';
|
||||
|
||||
export interface TextEditorTabViewProps {
|
||||
tabId: EditorTabId;
|
||||
/** When false the view is hidden via display:none so the Monaco instance persists. */
|
||||
isVisible: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
/** Host lookup for building the `host:remotePath` subtitle next to the filename. */
|
||||
hostById: Map<string, Host>;
|
||||
/** Routed into Monaco's Cmd/Ctrl+W command so closing the editor tab works
|
||||
* even when focus is inside the editor (Monaco otherwise swallows the event). */
|
||||
onRequestClose: (tabId: EditorTabId) => void;
|
||||
}
|
||||
|
||||
export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
tabId,
|
||||
isVisible,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
hostById,
|
||||
onRequestClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const tab = useEditorTab(tabId);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
|
||||
editorTabStore.updateContent(tabId, content, viewState);
|
||||
},
|
||||
[tabId],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(lang: string) => {
|
||||
editorTabStore.setLanguage(tabId, lang);
|
||||
},
|
||||
[tabId],
|
||||
);
|
||||
|
||||
const handleToggleWordWrap = useCallback(() => {
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return;
|
||||
editorTabStore.setWordWrap(tabId, !current.wordWrap);
|
||||
}, [tabId]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Read live store state at call time — React state snapshot lags the store
|
||||
// by one microtask, so a keystroke between onChange and this save would
|
||||
// otherwise leave us writing stale content and marking a stale baseline.
|
||||
const current = editorTabStore.getTab(tabId);
|
||||
if (!current) return;
|
||||
if (current.savingState === 'saving') return;
|
||||
|
||||
editorTabStore.setSavingState(tabId, 'saving');
|
||||
try {
|
||||
await editorSftpWrite(current.sessionId, current.hostId, current.remotePath, current.content);
|
||||
editorTabStore.markSaved(tabId, current.content);
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
|
||||
editorTabStore.setSavingState(tabId, 'error', msg);
|
||||
toast.error(msg, 'SFTP');
|
||||
}
|
||||
}, [tabId, t]);
|
||||
|
||||
// Tab has been closed — render nothing (parent should remove this instance,
|
||||
// but guard here in case of a transient render before unmount).
|
||||
if (!tab) return null;
|
||||
|
||||
const isDirty = tab.content !== tab.baselineContent;
|
||||
// Subtitle shown next to the filename in the Pane header, e.g.
|
||||
// "Rainyun-114.66.26.174:/root/hello-server.go". Falls back to hostId when
|
||||
// we don't have a Host record (session may have been removed).
|
||||
const host = hostById.get(tab.hostId);
|
||||
const hostLabel = host?.label ?? tab.hostId;
|
||||
const subtitle = `${hostLabel}:${tab.remotePath}`;
|
||||
|
||||
return (
|
||||
// Sibling tab panels (VaultView, SftpView, TerminalLayerMount, LogView)
|
||||
// all fill their flex-1 parent via `absolute inset-0`. Match that here so
|
||||
// an inactive editor tab doesn't collapse to zero height in normal flow,
|
||||
// and an active one fills the viewport instead of stacking beneath others.
|
||||
// z-index high enough to stay above the TerminalLayer's inner `z-10` panels
|
||||
// (TerminalLayer root is visibility:hidden when editor tabs are active, but
|
||||
// its children's stacking contexts can still overlap without an explicit z.)
|
||||
<div
|
||||
style={{ display: isVisible ? undefined : 'none', zIndex: 20 }}
|
||||
className="absolute inset-0 min-h-0 flex flex-col bg-background"
|
||||
>
|
||||
<TextEditorPane
|
||||
chrome="tab"
|
||||
fileName={`${tab.fileName}${isDirty ? ' *' : ''}`}
|
||||
subtitle={subtitle}
|
||||
onRequestClose={() => onRequestClose(tabId)}
|
||||
content={tab.content}
|
||||
languageId={tab.languageId}
|
||||
wordWrap={tab.wordWrap}
|
||||
saving={tab.savingState === 'saving'}
|
||||
saveError={tab.saveError}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onContentChange={handleContentChange}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onToggleWordWrap={handleToggleWordWrap}
|
||||
onSave={handleSave}
|
||||
initialViewState={tab.viewState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextEditorTabView;
|
||||
104
components/editor/UnsavedChangesDialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
|
||||
export type UnsavedChoice = "save" | "discard" | "cancel";
|
||||
|
||||
interface Pending {
|
||||
fileName: string;
|
||||
resolve: (choice: UnsavedChoice) => void;
|
||||
}
|
||||
|
||||
interface UnsavedChangesAPI {
|
||||
prompt: (fileName: string) => Promise<UnsavedChoice>;
|
||||
}
|
||||
|
||||
export const UnsavedChangesProvider: React.FC<{
|
||||
children: (api: UnsavedChangesAPI) => React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const { t } = useI18n();
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const pendingRef = useRef<Pending | null>(null);
|
||||
pendingRef.current = pending;
|
||||
|
||||
const prompt = useCallback(
|
||||
(fileName: string) =>
|
||||
new Promise<UnsavedChoice>((resolve) => {
|
||||
// Re-entrance: if a prior prompt is still pending, cancel it so its caller
|
||||
// doesn't hang forever waiting for a resolve that now belongs to a new prompt.
|
||||
const prior = pendingRef.current;
|
||||
if (prior) prior.resolve("cancel");
|
||||
setPending({ fileName, resolve });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Register the prompt function as the module-level singleton so it can be
|
||||
// called from outside the React tree (e.g. useSftpViewPaneActions).
|
||||
useEffect(() => {
|
||||
promptSingleton = prompt;
|
||||
return () => { promptSingleton = null; };
|
||||
}, [prompt]);
|
||||
|
||||
// On unmount, resolve any in-flight prompt as "cancel" so awaiting callers don't leak.
|
||||
useEffect(() => () => {
|
||||
const prior = pendingRef.current;
|
||||
if (prior) {
|
||||
prior.resolve("cancel");
|
||||
pendingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resolveWith = useCallback((choice: UnsavedChoice) => {
|
||||
if (!pending) return;
|
||||
pending.resolve(choice);
|
||||
setPending(null);
|
||||
}, [pending]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ prompt })}
|
||||
<Dialog open={!!pending} onOpenChange={(o) => { if (!o) resolveWith("cancel"); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.editor.unsavedTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.editor.unsavedMessage", { fileName: pending?.fileName ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={() => resolveWith("cancel")}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => resolveWith("discard")}>
|
||||
{t("sftp.editor.discardChanges")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => resolveWith("save")}>
|
||||
{t("sftp.editor.saveAndClose")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level singleton — lets non-React code call the dialog without
|
||||
// prop-drilling. Registered/unregistered by UnsavedChangesProvider above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let promptSingleton: ((fileName: string) => Promise<UnsavedChoice>) | null = null;
|
||||
|
||||
export const promptUnsavedChanges = (fileName: string): Promise<UnsavedChoice> => {
|
||||
if (!promptSingleton) return Promise.resolve("cancel");
|
||||
return promptSingleton(fileName);
|
||||
};
|
||||
@@ -20,7 +20,10 @@ export interface SftpTransferSource {
|
||||
// Types for the context
|
||||
export interface SftpPaneCallbacks {
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
/** Resolves true if disconnect completed, false if the user canceled the
|
||||
* dirty-editor prompt. Callers that follow up with a replacement connect
|
||||
* must gate on the result. */
|
||||
onDisconnect: () => Promise<boolean>;
|
||||
onPrepareSelection: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
@@ -49,6 +52,7 @@ export interface SftpPaneCallbacks {
|
||||
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { useSftpState } from "../../application/state/useSftpState";
|
||||
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import type { TextEditorModalSnapshot } from "../TextEditorModal";
|
||||
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
|
||||
import { SftpTransferQueue } from "./SftpTransferQueue";
|
||||
|
||||
@@ -44,6 +45,7 @@ interface SftpOverlaysProps {
|
||||
setFileOpenerTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
|
||||
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
|
||||
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
}
|
||||
|
||||
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
@@ -80,6 +82,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
setFileOpenerTarget,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onPromoteToTab,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -146,6 +149,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
|
||||
@@ -61,7 +61,7 @@ interface SftpPaneDialogsProps {
|
||||
hostSearch: string;
|
||||
setHostSearch: (value: string) => void;
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
onDisconnect: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
|
||||
@@ -357,13 +357,16 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
side={side}
|
||||
hostSearch={hostSearch}
|
||||
onHostSearchChange={setHostSearch}
|
||||
onSelectLocal={() => {
|
||||
onDisconnect();
|
||||
onConnect("local");
|
||||
onSelectLocal={async () => {
|
||||
// Only connect to the new target if the disconnect actually happened.
|
||||
// A cancel on the dirty-editor prompt must keep the user on the
|
||||
// current host instead of silently switching and stranding tabs.
|
||||
const ok = await onDisconnect();
|
||||
if (ok) onConnect("local");
|
||||
}}
|
||||
onSelectHost={(host) => {
|
||||
onDisconnect();
|
||||
onConnect(host);
|
||||
onSelectHost={async (host) => {
|
||||
const ok = await onDisconnect();
|
||||
if (ok) onConnect(host);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -58,6 +58,7 @@ interface SftpPaneFileListProps {
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void;
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
||||
onEditPermissions?: (entry: SftpFileEntry) => void;
|
||||
openRenameDialog: (name: string) => void;
|
||||
openDeleteConfirm: (targets: string[]) => void;
|
||||
@@ -143,6 +144,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onOpenFileWith,
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onEditPermissions,
|
||||
openRenameDialog,
|
||||
openDeleteConfirm,
|
||||
@@ -243,7 +245,23 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
)}
|
||||
{onDownloadFile &&
|
||||
(!isNavigableDirectory(entry) || !pane.connection?.isLocal) && (
|
||||
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
if (
|
||||
onDownloadFiles &&
|
||||
currentSelected.has(entry.name) &&
|
||||
currentSelected.size > 1
|
||||
) {
|
||||
const entries = Array.from(currentSelected)
|
||||
.map((name) => filesByName.get(String(name)))
|
||||
.filter((f): f is SftpFileEntry => !!f);
|
||||
onDownloadFiles(entries);
|
||||
} else {
|
||||
onDownloadFile(entry);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
@@ -349,6 +367,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onCopyToOtherPane,
|
||||
onMoveEntriesToPath,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
|
||||
@@ -570,6 +570,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onOpenFileWith={callbacks.onOpenFileWith}
|
||||
onEditFile={callbacks.onEditFile}
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onDownloadFiles={callbacks.onDownloadFiles}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
openRenameDialog={openRenameDialog}
|
||||
openDeleteConfirm={openDeleteConfirm}
|
||||
|
||||
@@ -5,8 +5,11 @@ import { getParentPath, joinPath as joinFsPath } from "../../../application/stat
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
|
||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||
|
||||
interface UseSftpViewFileOpsParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
@@ -80,6 +83,7 @@ interface UseSftpViewFileOpsResult {
|
||||
} | null>
|
||||
>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
onPromoteToTab: (snapshot: TextEditorModalSnapshot) => void;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
@@ -98,6 +102,8 @@ interface UseSftpViewFileOpsResult {
|
||||
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
|
||||
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
|
||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
}
|
||||
@@ -298,6 +304,31 @@ export const useSftpViewFileOps = ({
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handlePromoteToTab = useCallback((snapshot: TextEditorModalSnapshot) => {
|
||||
const target = textEditorTargetRef.current;
|
||||
if (!target) return;
|
||||
const pane = target.side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
const connection = pane.connection;
|
||||
if (!connection || !target.hostId) return;
|
||||
|
||||
const editorId = editorTabStore.promoteFromModal({
|
||||
sessionId: connection.id,
|
||||
hostId: target.hostId,
|
||||
remotePath: target.fullPath,
|
||||
fileName: target.file.name,
|
||||
languageId: snapshot.languageId || getLanguageId(target.file.name),
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
});
|
||||
activeTabStore.setActiveTabId(toEditorTabId(editorId));
|
||||
// Close the modal
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
}, [sftpRef]);
|
||||
|
||||
const onEditFileLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath),
|
||||
[handleEditFileForSide],
|
||||
@@ -589,6 +620,177 @@ export const useSftpViewFileOps = ({
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
// Multi-file download. For local panes, each file auto-downloads as a blob
|
||||
// (no prompt). For remote panes, prompts for a target directory once and
|
||||
// streams all selected entries into it — avoids the per-file save dialog
|
||||
// that would otherwise appear N times.
|
||||
const handleDownloadFilesForSide = useCallback(
|
||||
async (side: "left" | "right", files: SftpFileEntry[]) => {
|
||||
if (files.length === 0) return;
|
||||
if (files.length === 1) {
|
||||
await handleDownloadFileForSide(side, files[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
for (const file of files) {
|
||||
await handleDownloadFileForSide(side, file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectDirectory || !startStreamTransfer || !getSftpIdForConnection) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = getSftpIdForConnection(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
for (const file of files) {
|
||||
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
|
||||
try {
|
||||
if (isDirectory) {
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
connectionId: pane.connection.id,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory: true,
|
||||
});
|
||||
if (status === "completed") {
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "failed") {
|
||||
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const transferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
|
||||
sftpRef.current.addExternalUpload({
|
||||
id: transferId,
|
||||
fileName: file.name,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sourceConnectionId: pane.connection.id,
|
||||
targetConnectionId: "local",
|
||||
direction: "download",
|
||||
status: "transferring",
|
||||
totalBytes: fileSize,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
let errorHandled = false;
|
||||
|
||||
const result = await startStreamTransfer(
|
||||
{
|
||||
transferId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sourceType: "sftp",
|
||||
targetType: "local",
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: fileSize,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
},
|
||||
(transferred, total, speed) => {
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
speed,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: "completed",
|
||||
transferredBytes: fileSize,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
},
|
||||
(error) => {
|
||||
errorHandled = true;
|
||||
const isCancelError = error.includes("cancelled") || error.includes("canceled");
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: isCancelError ? "cancelled" : "failed",
|
||||
error: isCancelError ? undefined : error,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
if (!isCancelError) {
|
||||
toast.error(error, "SFTP");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (result === undefined) {
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: "failed",
|
||||
error: t("sftp.error.downloadFailed"),
|
||||
endTime: Date.now(),
|
||||
});
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result?.error && !errorHandled) {
|
||||
const isCancelError = result.error.includes("cancelled") || result.error.includes("canceled");
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: isCancelError ? "cancelled" : "failed",
|
||||
error: isCancelError ? undefined : result.error,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
if (!isCancelError) {
|
||||
toast.error(result.error, "SFTP");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to download file:", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
sftpRef,
|
||||
t,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
handleDownloadFileForSide,
|
||||
],
|
||||
);
|
||||
|
||||
const onDownloadFilesLeft = useCallback(
|
||||
(files: SftpFileEntry[]) => handleDownloadFilesForSide("left", files),
|
||||
[handleDownloadFilesForSide],
|
||||
);
|
||||
|
||||
const onDownloadFilesRight = useCallback(
|
||||
(files: SftpFileEntry[]) => handleDownloadFilesForSide("right", files),
|
||||
[handleDownloadFilesForSide],
|
||||
);
|
||||
|
||||
const onOpenEntryLeft = useCallback(
|
||||
(entry: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = sftpRef.current.leftPane;
|
||||
@@ -664,6 +866,7 @@ export const useSftpViewFileOps = ({
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab: handlePromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onEditPermissionsLeft,
|
||||
@@ -678,6 +881,8 @@ export const useSftpViewFileOps = ({
|
||||
onOpenFileWithRight,
|
||||
onDownloadFileLeft,
|
||||
onDownloadFileRight,
|
||||
onDownloadFilesLeft,
|
||||
onDownloadFilesRight,
|
||||
onUploadExternalFilesLeft,
|
||||
onUploadExternalFilesRight,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { MutableRefObject } from "react";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { keepOnlyActivePaneSelections } from "./selectionScope";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
|
||||
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
|
||||
|
||||
interface UseSftpViewPaneActionsParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
@@ -13,8 +16,8 @@ interface UseSftpViewPaneActionsResult {
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
onConnectLeft: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
|
||||
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
|
||||
onDisconnectLeft: () => void;
|
||||
onDisconnectRight: () => void;
|
||||
onDisconnectLeft: () => Promise<boolean>;
|
||||
onDisconnectRight: () => Promise<boolean>;
|
||||
onPrepareSelectionLeft: () => void;
|
||||
onPrepareSelectionRight: () => void;
|
||||
onNavigateToLeft: (path: string) => void;
|
||||
@@ -127,8 +130,42 @@ export const useSftpViewPaneActions = ({
|
||||
(host: Parameters<SftpStateApi["connect"]>[1]) => sftpRef.current.connect("right", host),
|
||||
[sftpRef],
|
||||
);
|
||||
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
|
||||
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
|
||||
// Returns `true` if the disconnect actually happened, `false` if the user
|
||||
// canceled the dirty-editor prompt. Callers that kick off a replacement
|
||||
// connect (e.g. the host picker) MUST gate their follow-up on this result
|
||||
// so a canceled prompt doesn't silently drop the user onto a new host.
|
||||
const onDisconnectLeft = useCallback(async (): Promise<boolean> => {
|
||||
const connectionId = sftpRef.current.getActivePane("left")?.connection?.id;
|
||||
if (connectionId) {
|
||||
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
|
||||
const saveTab = async (id: EditorTabId) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
|
||||
editorTabStore.markSaved(id, tab.content);
|
||||
};
|
||||
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
|
||||
if (!ok) return false;
|
||||
}
|
||||
sftpRef.current.disconnect("left");
|
||||
return true;
|
||||
}, [sftpRef]);
|
||||
const onDisconnectRight = useCallback(async (): Promise<boolean> => {
|
||||
const connectionId = sftpRef.current.getActivePane("right")?.connection?.id;
|
||||
if (connectionId) {
|
||||
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
|
||||
const saveTab = async (id: EditorTabId) => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return;
|
||||
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
|
||||
editorTabStore.markSaved(id, tab.content);
|
||||
};
|
||||
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
|
||||
if (!ok) return false;
|
||||
}
|
||||
sftpRef.current.disconnect("right");
|
||||
return true;
|
||||
}, [sftpRef]);
|
||||
const onPrepareSelectionLeft = useCallback(() => {
|
||||
keepOnlyActivePaneSelections(sftpRef.current, "left");
|
||||
}, [sftpRef]);
|
||||
|
||||
@@ -169,6 +169,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onOpenFile: fileOps.onOpenFileLeft,
|
||||
onOpenFileWith: fileOps.onOpenFileWithLeft,
|
||||
onDownloadFile: fileOps.onDownloadFileLeft,
|
||||
onDownloadFiles: fileOps.onDownloadFilesLeft,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
||||
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
|
||||
}),
|
||||
@@ -206,6 +207,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onOpenFile: fileOps.onOpenFileRight,
|
||||
onOpenFileWith: fileOps.onOpenFileWithRight,
|
||||
onDownloadFile: fileOps.onDownloadFileRight,
|
||||
onDownloadFiles: fileOps.onDownloadFilesRight,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
||||
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
|
||||
}),
|
||||
@@ -232,6 +234,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
fileOpenerTarget: fileOps.fileOpenerTarget,
|
||||
setFileOpenerTarget: fileOps.setFileOpenerTarget,
|
||||
handleSaveTextFile: fileOps.handleSaveTextFile,
|
||||
onPromoteToTab: fileOps.onPromoteToTab,
|
||||
handleFileOpenerSelect: fileOps.handleFileOpenerSelect,
|
||||
handleSelectSystemApp: fileOps.handleSelectSystemApp,
|
||||
};
|
||||
|
||||
22
components/terminal/GhostSuggestionPolicy.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { decideGhostSuggestion } from "./autocomplete/ghostSuggestionPolicy.ts";
|
||||
|
||||
test("keeps the active ghost suggestion while input still fits it", () => {
|
||||
const decision = decideGhostSuggestion("docker ps -a", "doc", "docker compose ls");
|
||||
|
||||
assert.deepEqual(decision, { type: "keep" });
|
||||
});
|
||||
|
||||
test("switches to a new suggestion once the active one no longer matches", () => {
|
||||
const decision = decideGhostSuggestion("docker ps -a", "dog", "dogstatsd");
|
||||
|
||||
assert.deepEqual(decision, { type: "show", suggestion: "dogstatsd" });
|
||||
});
|
||||
|
||||
test("hides the ghost when neither the active nor next suggestion matches", () => {
|
||||
const decision = decideGhostSuggestion("docker ps -a", "dog", null);
|
||||
|
||||
assert.deepEqual(decision, { type: "hide" });
|
||||
});
|
||||
325
components/terminal/GhostTextAddon.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { GhostTextAddon } from "./autocomplete/GhostTextAddon.ts";
|
||||
|
||||
type RenderListener = () => void;
|
||||
type ResizeListener = () => void;
|
||||
|
||||
class FakeElement {
|
||||
public readonly style: Record<string, string> = {};
|
||||
public textContent = "";
|
||||
public className = "";
|
||||
public children: FakeElement[] = [];
|
||||
|
||||
appendChild(child: FakeElement): FakeElement {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
insertBefore(child: FakeElement, referenceNode: FakeElement | null): FakeElement {
|
||||
if (!referenceNode) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
const index = this.children.indexOf(referenceNode);
|
||||
if (index < 0) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
this.children.splice(index, 0, child);
|
||||
return child;
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
// No-op for tests.
|
||||
}
|
||||
|
||||
querySelector(selector: string): FakeElement | null {
|
||||
if (selector === ".xterm-screen") {
|
||||
return this.children.find((child) => child.className === "xterm-screen") ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function installFakeDocument(): () => void {
|
||||
const previousDocument = globalThis.document;
|
||||
const fakeDocument = {
|
||||
createElement() {
|
||||
return new FakeElement();
|
||||
},
|
||||
} as unknown as Document;
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: fakeDocument,
|
||||
});
|
||||
return () => {
|
||||
if (previousDocument === undefined) {
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: previousDocument,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeTerm() {
|
||||
const renderListeners: RenderListener[] = [];
|
||||
const resizeListeners: ResizeListener[] = [];
|
||||
const element = new FakeElement();
|
||||
const screen = new FakeElement();
|
||||
screen.className = "xterm-screen";
|
||||
element.appendChild(screen);
|
||||
|
||||
const term = {
|
||||
element,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: {
|
||||
fontSize: 14,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX: 2,
|
||||
cursorY: 0,
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
_renderService: {
|
||||
dimensions: {
|
||||
css: {
|
||||
cell: {
|
||||
width: 9,
|
||||
height: 18,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
onRender(listener: RenderListener) {
|
||||
renderListeners.push(listener);
|
||||
return {
|
||||
dispose() {
|
||||
const index = renderListeners.indexOf(listener);
|
||||
if (index >= 0) renderListeners.splice(index, 1);
|
||||
},
|
||||
};
|
||||
},
|
||||
onResize(listener: ResizeListener) {
|
||||
resizeListeners.push(listener);
|
||||
return {
|
||||
dispose() {
|
||||
const index = resizeListeners.indexOf(listener);
|
||||
if (index >= 0) resizeListeners.splice(index, 1);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
term,
|
||||
ghostElement: () => screen.children[0]?.children[0] ?? null,
|
||||
fireRender() {
|
||||
for (const listener of [...renderListeners]) listener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("shifts ghost to predicted cursor column as matching input is typed", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.display, "block");
|
||||
assert.equal(ghost.textContent, "cker");
|
||||
// show() anchored at cursorX=2, cell width=9 → left=18.
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
|
||||
addon.adjustToInput("doc");
|
||||
|
||||
// After one matching char, the ghost predicts the cursor has moved
|
||||
// to column 3 and trims "c" from the tail so the next char starts
|
||||
// where the echo will land. Not waiting for xterm's render keeps
|
||||
// ghost + real input aligned across SSH echo latency.
|
||||
assert.equal(ghost.style.display, "block");
|
||||
assert.equal(ghost.textContent, "ker");
|
||||
assert.equal(ghost.style.left, "27px");
|
||||
assert.equal(addon.getGhostText(), "ker");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("walks the anchor column backwards on backspace so the ghost re-aligns", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
|
||||
addon.adjustToInput("doc");
|
||||
assert.equal(ghost.textContent, "ker");
|
||||
assert.equal(ghost.style.left, "27px");
|
||||
|
||||
// Backspace below the anchor input — the ghost should shift *left*,
|
||||
// not stay pinned at the show-time anchor column. Pinning would
|
||||
// leave a visual gap between the real cursor and the ghost.
|
||||
addon.adjustToInput("d");
|
||||
assert.equal(ghost.textContent, "ocker");
|
||||
// anchor was cursorX=2 captured at show(); "d" is 1 char below
|
||||
// anchorInputLength=2 → predicted cursor column = 1.
|
||||
assert.equal(ghost.style.left, "9px");
|
||||
|
||||
// Backspace past the anchor back to empty: left is clamped at 0.
|
||||
addon.adjustToInput("");
|
||||
assert.equal(ghost.textContent, "docker");
|
||||
assert.equal(ghost.style.left, "0px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("advances the anchor by two cells when a CJK glyph is typed", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// Suggestion starts with a CJK char so the prefix-match survives
|
||||
// the next keystroke.
|
||||
addon.show("你好世界", "");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
// show() anchored at cursorX=2. Input length 0 → delta 0 → left=18.
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
|
||||
addon.adjustToInput("你");
|
||||
|
||||
// One CJK char = 2 cells. Predicted col = 2 + 2 = 4 → left 36px.
|
||||
assert.equal(ghost.textContent, "好世界");
|
||||
assert.equal(ghost.style.left, "36px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("wraps the ghost to the next row when the predicted column crosses cols", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
// Shrink the terminal to 10 cols to keep the math obvious. Anchor at
|
||||
// col 8 with 5 ASCII chars to type → predicted col = 13, which should
|
||||
// wrap to col 3 of row 1.
|
||||
term.cols = 10;
|
||||
term.buffer.active.cursorX = 8;
|
||||
addon.activate(term as never);
|
||||
addon.show("abcdefghij", "ab");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.top, "0px");
|
||||
|
||||
addon.adjustToInput("abcde");
|
||||
|
||||
// Predicted col = 8 + (5-2) = 11 → wraps to col 1 on next row.
|
||||
// cellWidth=9, cellHeight=18.
|
||||
assert.equal(ghost.textContent, "fghij");
|
||||
assert.equal(ghost.style.left, "9px");
|
||||
assert.equal(ghost.style.top, "18px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("self-heals a stale anchor on render while no adjustToInput has fired", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// show() captures cursorX=2 — simulate this firing during the
|
||||
// keystroke→echo gap by later advancing the live cursor and
|
||||
// verifying the ghost anchor snaps to the echoed position.
|
||||
addon.show("docker", "do");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
|
||||
term.buffer.active.cursorX = 5;
|
||||
fireRender();
|
||||
|
||||
// Input hasn't moved from the show-time baseline, so updatePosition
|
||||
// re-reads live cursor: new left = 5 * 9 = 45px.
|
||||
assert.equal(ghost.style.left, "45px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("wraps the ghost to the previous row when deletion crosses a row boundary", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
term.cols = 10;
|
||||
term.buffer.active.cursorX = 1;
|
||||
term.buffer.active.cursorY = 1;
|
||||
addon.activate(term as never);
|
||||
// Anchored at row 1 col 1 with 5 chars already typed.
|
||||
addon.show("abcdefghij", "abcde");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
|
||||
// Backspace back to 2 chars — delta = -3 across a row boundary.
|
||||
addon.adjustToInput("ab");
|
||||
|
||||
// targetCol = 1 - 3 = -2 → col = 8 (wrapped) on row 0.
|
||||
assert.equal(ghost.textContent, "cdefghij");
|
||||
assert.equal(ghost.style.left, "72px");
|
||||
assert.equal(ghost.style.top, "0px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides ghost immediately when input no longer matches suggestion", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.display, "block");
|
||||
|
||||
addon.adjustToInput("dox");
|
||||
|
||||
assert.equal(ghost.style.display, "none");
|
||||
assert.equal(ghost.textContent, "");
|
||||
assert.equal(addon.isActive(), false);
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
49
components/terminal/PromptDetector.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getAlignedPrompt } from "./autocomplete/promptDetector.ts";
|
||||
|
||||
function createFakeTerm(lineText: string, cursorX: number) {
|
||||
return {
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY: 0,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
if (line !== 0) return undefined;
|
||||
return {
|
||||
isWrapped: false,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("prefers the typed buffer when shell echo is still one character behind", () => {
|
||||
const term = createFakeTerm("$ do", 4);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "doc", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "doc");
|
||||
assert.equal(result.prompt.cursorOffset, 3);
|
||||
assert.equal(result.alignedTyped, "doc");
|
||||
});
|
||||
|
||||
test("still trims prompt decorations out of the detected input", () => {
|
||||
const term = createFakeTerm("➜ ~ do", 7);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "do", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "➜ ~ ");
|
||||
assert.equal(result.prompt.userInput, "do");
|
||||
assert.equal(result.prompt.cursorOffset, 2);
|
||||
assert.equal(result.alignedTyped, "do");
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* Terminal Toolbar
|
||||
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
|
||||
*/
|
||||
import { Check, FolderInput, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import { Check, ChevronRight, FolderInput, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host } from '../../types';
|
||||
@@ -50,11 +50,24 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
|
||||
// Overflow popover + encoding submenu are both controlled so that
|
||||
// picking an encoding closes the whole chain, and so the parent popover
|
||||
// can ignore clicks that land in the submenu portal (otherwise the
|
||||
// submenu click would read as "outside" and dismiss the parent).
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
const [encodingSubmenuOpen, setEncodingSubmenuOpen] = useState(false);
|
||||
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
|
||||
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
|
||||
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
|
||||
const isMoshSession = host?.protocol === 'mosh' || host?.moshEnabled;
|
||||
// Local PTY inherits the OS locale and mosh always uses its own UTF-8
|
||||
// framing, so the quick-switch menu only makes sense for sessions whose
|
||||
// backend decoder we actually control (SSH, telnet, serial). Hostname
|
||||
// isn't part of the gate — telnet/SSH targets pointed at localhost
|
||||
// (test daemons, forwarded endpoints) still have a real backend
|
||||
// decoder we can drive.
|
||||
const encodingSwitchSupported = !isLocalTerminal && !isMoshSession;
|
||||
const hidesSftp = isLocalTerminal || isSerialTerminal;
|
||||
|
||||
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
|
||||
@@ -106,7 +119,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
single ⋮ trigger so the toolbar doesn't feel crowded.
|
||||
Highlight / Compose / Search stay visible because they
|
||||
are toggled mid-session, not just once. */}
|
||||
<Popover>
|
||||
<Popover
|
||||
open={overflowOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOverflowOpen(open);
|
||||
if (!open) setEncodingSubmenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -122,7 +141,19 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.more")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-48 p-1" align="end">
|
||||
<PopoverContent
|
||||
className="w-48 p-1"
|
||||
align="end"
|
||||
onInteractOutside={(e) => {
|
||||
// Radix treats the submenu's portalled content as
|
||||
// "outside" this popover; without this guard a click
|
||||
// in the submenu would dismiss the parent.
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest('[data-encoding-submenu="true"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!hidesSftp && (
|
||||
<PopoverClose asChild>
|
||||
<button
|
||||
@@ -150,32 +181,56 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
<span className="flex-1 text-left truncate">{t("terminal.toolbar.terminalSettings")}</span>
|
||||
</button>
|
||||
</PopoverClose>
|
||||
{isSSHSession && onSetTerminalEncoding && (
|
||||
<>
|
||||
<div className="h-px bg-border/60 my-1 mx-1" />
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
||||
<Languages size={11} />
|
||||
{t("terminal.toolbar.encoding")}
|
||||
</div>
|
||||
{(["utf-8", "gb18030"] as const).map((enc) => (
|
||||
<PopoverClose asChild key={enc}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(menuItemClass, "pl-6", terminalEncoding === enc && "font-medium")}
|
||||
onClick={() => onSetTerminalEncoding(enc)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
terminalEncoding === enc ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</>
|
||||
{encodingSwitchSupported && onSetTerminalEncoding && (
|
||||
<Popover open={encodingSubmenuOpen} onOpenChange={setEncodingSubmenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={encodingSubmenuOpen}
|
||||
>
|
||||
<Languages size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t("terminal.toolbar.encoding")}</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-encoding-submenu="true"
|
||||
className="w-40 p-1"
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
>
|
||||
{(["utf-8", "gb18030"] as const).map((enc) => {
|
||||
const isActive = terminalEncoding === enc;
|
||||
return (
|
||||
<button
|
||||
key={enc}
|
||||
type="button"
|
||||
className={cn(menuItemClass, isActive && "font-medium")}
|
||||
onClick={() => {
|
||||
onSetTerminalEncoding(enc);
|
||||
setEncodingSubmenuOpen(false);
|
||||
setOverflowOpen(false);
|
||||
}}
|
||||
>
|
||||
<Languages size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
|
||||
</span>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -10,12 +10,56 @@
|
||||
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
|
||||
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
|
||||
|
||||
/**
|
||||
* Minimal East-Asian-Width-style classifier: returns 2 for wide glyphs
|
||||
* (CJK ideographs, fullwidth forms, most emoji, hangul syllables) and
|
||||
* 1 otherwise. Not full wcwidth — just enough to keep the predicted
|
||||
* ghost column from drifting by one cell per CJK char typed.
|
||||
*/
|
||||
function codePointCellWidth(cp: number): number {
|
||||
if (
|
||||
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
||||
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals, Kangxi
|
||||
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK Compat
|
||||
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
|
||||
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
||||
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
|
||||
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
||||
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
|
||||
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
|
||||
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
|
||||
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
|
||||
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji blocks
|
||||
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Extension B-F, G
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function stringCellWidth(s: string): number {
|
||||
let w = 0;
|
||||
for (const ch of s) {
|
||||
const cp = ch.codePointAt(0) ?? 0;
|
||||
w += codePointCellWidth(cp);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
export class GhostTextAddon implements IDisposable {
|
||||
private term: XTerm | null = null;
|
||||
private ghostElement: HTMLSpanElement | null = null;
|
||||
private containerElement: HTMLDivElement | null = null;
|
||||
private currentSuggestion: string = "";
|
||||
private currentInput: string = "";
|
||||
/** Cursor column captured at show() time — the anchor the ghost was painted from. */
|
||||
private anchorCursorX = 0;
|
||||
/** Cursor row captured at show() time. */
|
||||
private anchorCursorY = 0;
|
||||
/** Length of currentInput at show() time — lets adjustToInput shift left
|
||||
* by (newInput.length - anchorInputLength) cells without having to
|
||||
* re-read xterm's cursorX (which hasn't advanced yet at keystroke time). */
|
||||
private anchorInputLength = 0;
|
||||
private disposed = false;
|
||||
private disposables: IDisposable[] = [];
|
||||
private lastLeft = -1;
|
||||
@@ -37,6 +81,9 @@ export class GhostTextAddon implements IDisposable {
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
// Sit above xterm's canvas — xterm's default renderer paints its
|
||||
// theme.background across every cell including empty ones, so a
|
||||
// ghost placed beneath the canvas would be completely occluded.
|
||||
zIndex: "1",
|
||||
});
|
||||
|
||||
@@ -63,17 +110,25 @@ export class GhostTextAddon implements IDisposable {
|
||||
termElement.appendChild(this.containerElement);
|
||||
}
|
||||
|
||||
// Update position on scroll and render to keep ghost text aligned
|
||||
this.disposables.push(
|
||||
term.onRender(() => {
|
||||
if (this.isVisible()) this.updatePosition();
|
||||
if (this.isVisible()) {
|
||||
this.updatePosition();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Invalidate cell dimension cache on resize so measurements stay accurate
|
||||
// Invalidate cell dimension cache on resize so measurements stay
|
||||
// accurate, and force a pixel-coord recompute on the next render —
|
||||
// otherwise the lastLeft/lastTop short-circuit in updatePosition
|
||||
// would keep the ghost at stale pixel coordinates until the user
|
||||
// typed again.
|
||||
this.disposables.push(
|
||||
term.onResize(() => {
|
||||
invalidateCellDimensionCache();
|
||||
this.lastLeft = -1;
|
||||
this.lastTop = -1;
|
||||
if (this.isVisible()) this.updatePosition();
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -97,6 +152,12 @@ export class GhostTextAddon implements IDisposable {
|
||||
|
||||
this.currentSuggestion = fullSuggestion;
|
||||
this.currentInput = currentInput;
|
||||
this.anchorCursorX = this.term.buffer.active.cursorX;
|
||||
this.anchorCursorY = this.term.buffer.active.cursorY;
|
||||
this.anchorInputLength = currentInput.length;
|
||||
// Force position recalc since the text also changed.
|
||||
this.lastLeft = -1;
|
||||
this.lastTop = -1;
|
||||
|
||||
this.updatePosition();
|
||||
this.ghostElement.textContent = ghostText;
|
||||
@@ -113,6 +174,43 @@ export class GhostTextAddon implements IDisposable {
|
||||
}
|
||||
this.currentSuggestion = "";
|
||||
this.currentInput = "";
|
||||
this.anchorInputLength = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-align the ghost against a freshly-updated user input synchronously.
|
||||
* Called from handleInput on every keystroke that mutates the typed
|
||||
* buffer so ghost text never falls out of sync with what the user has
|
||||
* actually typed.
|
||||
*
|
||||
* Implementation relies on the predict-anchor-shift trick rather than
|
||||
* re-reading xterm's live cursorX: xterm hasn't echoed the triggering
|
||||
* keystroke yet at this point, so cursorX still points at the
|
||||
* pre-keystroke column. Instead we track the cursor column captured
|
||||
* at show() time and advance the ghost's left by the number of chars
|
||||
* typed since — so the tail aligns with where the real cursor *will*
|
||||
* land once the echo arrives, even across SSH round-trip latency.
|
||||
*/
|
||||
adjustToInput(newInput: string): void {
|
||||
if (this.disposed || !this.ghostElement || !this.currentSuggestion) return;
|
||||
if (!this.currentSuggestion.startsWith(newInput)) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.currentInput = newInput;
|
||||
const ghostText = this.currentSuggestion.substring(newInput.length);
|
||||
if (!ghostText) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
// Force position recomputation — updatePosition skips DOM writes
|
||||
// when the left/top cache hasn't changed, but we also need the new
|
||||
// textContent to flush.
|
||||
this.lastLeft = -1;
|
||||
this.lastTop = -1;
|
||||
this.ghostElement.textContent = ghostText;
|
||||
this.updatePosition();
|
||||
this.ghostElement.style.display = "block";
|
||||
}
|
||||
|
||||
getSuggestion(): string {
|
||||
@@ -124,6 +222,17 @@ export class GhostTextAddon implements IDisposable {
|
||||
this.currentSuggestion);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the ghost has a live suggestion even if it's momentarily
|
||||
* shown underneath the real text while the user keeps typing within
|
||||
* the prediction. Accept-path gates should use this instead of
|
||||
* isVisible() so the suggestion remains available even while its
|
||||
* leading characters are fully covered by real glyphs.
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return !this.disposed && !!this.currentSuggestion;
|
||||
}
|
||||
|
||||
getGhostText(): string {
|
||||
if (!this.currentSuggestion || !this.currentInput) return "";
|
||||
return this.currentSuggestion.startsWith(this.currentInput)
|
||||
@@ -151,11 +260,47 @@ export class GhostTextAddon implements IDisposable {
|
||||
private updatePosition(): void {
|
||||
if (!this.term || !this.ghostElement) return;
|
||||
|
||||
// Self-heal a stale anchor: when show() fires during the SSH
|
||||
// keystroke→echo gap, cursorX captured there is still the
|
||||
// pre-echo column. While no adjustToInput has moved us from the
|
||||
// show-time baseline, re-read live cursor on each render tick so
|
||||
// the anchor snaps to the echoed position once it arrives.
|
||||
if (this.currentInput.length === this.anchorInputLength) {
|
||||
this.anchorCursorX = this.term.buffer.active.cursorX;
|
||||
this.anchorCursorY = this.term.buffer.active.cursorY;
|
||||
}
|
||||
|
||||
const dims = getXTermCellDimensions(this.term);
|
||||
|
||||
const buffer = this.term.buffer.active;
|
||||
const left = buffer.cursorX * dims.width;
|
||||
const top = buffer.cursorY * dims.height;
|
||||
// Advance (or walk back) the anchor column by the cell width of
|
||||
// whatever the user has typed since show() was called. Using cell
|
||||
// width (not code-unit length) lets CJK / emoji / fullwidth glyphs
|
||||
// advance by 2 cells instead of 1. Backspace / Ctrl-W produces a
|
||||
// negative delta by shrinking currentInput below anchorInputLength.
|
||||
const cellDelta = this.currentInput.length >= this.anchorInputLength
|
||||
? stringCellWidth(this.currentInput.slice(this.anchorInputLength))
|
||||
: -stringCellWidth(
|
||||
// currentSuggestion[0..anchorInputLength] equals what was typed
|
||||
// when show() fired (prefix-match invariant), so its slice gives
|
||||
// the correct cell widths for the deleted glyphs.
|
||||
this.currentSuggestion.slice(this.currentInput.length, this.anchorInputLength),
|
||||
);
|
||||
const cols = Math.max(1, this.term.cols);
|
||||
const targetCol = this.anchorCursorX + cellDelta;
|
||||
// Wrap the predicted cursor position across line boundaries in both
|
||||
// directions — the real xterm cursor wraps to the next row once it
|
||||
// crosses cols forward, and to the previous row when a deletion
|
||||
// crosses back past column 0. JS `%` returns negative for negative
|
||||
// dividends, so normalize both col and rowOffset explicitly.
|
||||
let col = targetCol % cols;
|
||||
let rowOffset = Math.floor(targetCol / cols);
|
||||
if (col < 0) {
|
||||
col += cols;
|
||||
}
|
||||
// Clamp to the visible top row so a runaway negative delta (e.g.
|
||||
// deleted past the prompt) doesn't render above the terminal.
|
||||
const top = Math.max(0, this.anchorCursorY + rowOffset) * dims.height;
|
||||
const left = col * dims.width;
|
||||
|
||||
// Skip DOM writes if position hasn't changed (avoids unnecessary style recalc)
|
||||
if (left === this.lastLeft && top === this.lastTop) return;
|
||||
|
||||
@@ -66,6 +66,14 @@ export interface CompletionContext {
|
||||
isOptionArg: boolean;
|
||||
}
|
||||
|
||||
export function shellEscape(name: string): string {
|
||||
if (!name) return name;
|
||||
if (/[\\$'"|!<>;#~` ]/.test(name)) {
|
||||
return `'${name.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a command line string into tokens, handling quoting.
|
||||
*/
|
||||
@@ -241,9 +249,9 @@ export async function getCompletions(
|
||||
const { pathPrefix, quoteSuffix } = resolvePathComponents(ctx.currentWord, options.cwd);
|
||||
const isQuotedPath = ctx.currentWord.startsWith('"') || ctx.currentWord.startsWith("'");
|
||||
for (const entry of pathEntries) {
|
||||
const insertName = isQuotedPath || !entry.name.includes(" ")
|
||||
const insertName = isQuotedPath || !/[\\$'"|!<>;#~` ]/.test(entry.name)
|
||||
? entry.name
|
||||
: entry.name.replace(/ /g, "\\ ");
|
||||
: shellEscape(entry.name);
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const fullPath = pathPrefix + insertName + suffix + quoteSuffix;
|
||||
const suggestion = {
|
||||
|
||||
24
components/terminal/autocomplete/ghostSuggestionPolicy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type GhostSuggestionDecision =
|
||||
| { type: "keep" }
|
||||
| { type: "show"; suggestion: string }
|
||||
| { type: "hide" };
|
||||
|
||||
/**
|
||||
* Prefer a stable ghost suggestion while the user's typed input still
|
||||
* falls within the currently shown prediction. This avoids a "jitter"
|
||||
* effect where freshly fetched suggestions keep replacing the same
|
||||
* visual prediction one character at a time.
|
||||
*/
|
||||
export function decideGhostSuggestion(
|
||||
activeSuggestion: string | null,
|
||||
input: string,
|
||||
nextSuggestion: string | null,
|
||||
): GhostSuggestionDecision {
|
||||
if (activeSuggestion && activeSuggestion.startsWith(input)) {
|
||||
return { type: "keep" };
|
||||
}
|
||||
if (nextSuggestion && nextSuggestion.startsWith(input)) {
|
||||
return { type: "show", suggestion: nextSuggestion };
|
||||
}
|
||||
return { type: "hide" };
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandl
|
||||
export { default as AutocompletePopup } from "./AutocompletePopup";
|
||||
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
|
||||
export { recordCommand, clearHistory, deleteHistoryEntry, getHistoryCount } from "./commandHistoryStore";
|
||||
export { shellEscape } from "./completionEngine";
|
||||
|
||||
@@ -38,6 +38,30 @@ const NO_PROMPT: PromptDetectionResult = {
|
||||
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
|
||||
};
|
||||
|
||||
export interface AlignedPromptResult {
|
||||
/** The prompt view every consumer should use for parsing / suggestion lookup / line rewrites. */
|
||||
prompt: PromptDetectionResult;
|
||||
/**
|
||||
* The keystroke buffer, but only when it's both marked reliable AND
|
||||
* actually matches the tail of the raw detected userInput. Returns
|
||||
* null otherwise — the single signal downstream uses to decide
|
||||
* whether to record it as the executed command.
|
||||
*/
|
||||
alignedTyped: string | null;
|
||||
}
|
||||
|
||||
function replacePromptUserInput(
|
||||
prompt: PromptDetectionResult,
|
||||
userInput: string,
|
||||
): PromptDetectionResult {
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText,
|
||||
userInput,
|
||||
cursorOffset: userInput.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
|
||||
*/
|
||||
@@ -205,6 +229,92 @@ function findPromptBoundary(lineText: string): number {
|
||||
return lastBoundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a buffer-parsed prompt with the user's own keystroke history.
|
||||
*
|
||||
* findPromptBoundary stops at the first `PROMPT_CHAR + space` it sees, so
|
||||
* themes that render additional content after the prompt char — e.g.
|
||||
* oh-my-zsh's robbyrussell prints "➜ ~ " where `~` is the cwd — get
|
||||
* parsed as prompt="➜ " + userInput="~ lo". Every consumer downstream
|
||||
* (history recording, suggestion matching, insertion) then treats the
|
||||
* theme's cwd marker as part of the user's command, which pollutes
|
||||
* history with entries like "~ sudo id" and makes Tab insertions prepend
|
||||
* a phantom "~ " to the typed command (issue #806).
|
||||
*
|
||||
* Whenever we have an independent record of what the user actually typed
|
||||
* since the last Enter (keystroke buffer), we can detect this case: the
|
||||
* real input is always a suffix of the over-captured userInput. When it
|
||||
* is, reattribute the leading garbage back to promptText so the rest of
|
||||
* the pipeline sees the clean split.
|
||||
*/
|
||||
export function reconcilePromptWithTypedInput(
|
||||
prompt: PromptDetectionResult,
|
||||
typedInput: string,
|
||||
): PromptDetectionResult {
|
||||
if (!prompt.isAtPrompt) return prompt;
|
||||
if (!typedInput) return prompt;
|
||||
if (prompt.userInput === typedInput) return prompt;
|
||||
if (
|
||||
prompt.userInput.length > typedInput.length &&
|
||||
prompt.userInput.endsWith(typedInput)
|
||||
) {
|
||||
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText + extra,
|
||||
userInput: typedInput,
|
||||
cursorOffset: typedInput.length,
|
||||
};
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry point for any autocomplete code path that needs a prompt
|
||||
* view. Every consumer (fetchSuggestions, insertSuggestion,
|
||||
* handleSubDirSelect, Enter-record) goes through this one helper so the
|
||||
* alignment policy lives in exactly one place — if another out-of-band
|
||||
* line-rewrite path gets added later and forgets to notify the keystroke
|
||||
* buffer, the worst that happens is reconcile no-ops and we degrade to
|
||||
* pre-#806 behavior, not a worse pollution.
|
||||
*
|
||||
* Alignment rule: the keystroke buffer is usable only when it's marked
|
||||
* reliable AND the raw detected prompt still looks like the same shell
|
||||
* line. When the raw buffer has either over-captured prompt chrome
|
||||
* (`raw.userInput.endsWith(typedBuffer)`) or under-captured because the
|
||||
* shell echo/render is lagging behind local keystrokes
|
||||
* (`typedBuffer.startsWith(raw.userInput)`), prefer the typed buffer.
|
||||
* Otherwise the buffer is ignored and the raw detector result passes
|
||||
* through.
|
||||
*/
|
||||
export function getAlignedPrompt(
|
||||
term: XTerm | null,
|
||||
typedBuffer: string,
|
||||
typedReliable: boolean,
|
||||
): AlignedPromptResult {
|
||||
if (!term) return { prompt: NO_PROMPT, alignedTyped: null };
|
||||
const raw = detectPrompt(term);
|
||||
if (!typedReliable || typedBuffer.length === 0 || !raw.isAtPrompt) {
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
if (raw.userInput === typedBuffer) {
|
||||
return { prompt: raw, alignedTyped: typedBuffer };
|
||||
}
|
||||
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
|
||||
return {
|
||||
prompt: reconcilePromptWithTypedInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
if (typedBuffer.length > raw.userInput.length && typedBuffer.startsWith(raw.userInput)) {
|
||||
return {
|
||||
prompt: replacePromptUserInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified prompt detection: just check if we're likely at a prompt.
|
||||
*/
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { GhostTextAddon } from "./GhostTextAddon";
|
||||
import { detectPrompt, type PromptDetectionResult } from "./promptDetector";
|
||||
import { getAlignedPrompt, type PromptDetectionResult } from "./promptDetector";
|
||||
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
|
||||
import { recordCommand } from "./commandHistoryStore";
|
||||
import { shellEscape } from "./completionEngine";
|
||||
import { preloadCommonSpecs } from "./figSpecLoader";
|
||||
import { getXTermCellDimensions } from "./xtermUtils";
|
||||
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
|
||||
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
|
||||
|
||||
export interface AutocompleteSettings {
|
||||
enabled: boolean;
|
||||
@@ -64,6 +66,8 @@ export interface SubDirPanel {
|
||||
dirPath: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface AutocompleteState {
|
||||
suggestions: CompletionSuggestion[];
|
||||
selectedIndex: number;
|
||||
@@ -111,6 +115,13 @@ export function useTerminalAutocomplete(
|
||||
...DEFAULT_AUTOCOMPLETE_SETTINGS,
|
||||
...userSettings,
|
||||
};
|
||||
// Mutual-exclusivity guard matching the repo-wide contract:
|
||||
// - SettingsTerminalTab toggles one off when the other is enabled.
|
||||
// - domain/models.ts normalizes stored settings so popup wins.
|
||||
// Keep the guard here too so callers that pass DEFAULT_AUTOCOMPLETE_SETTINGS
|
||||
// directly (e.g. tests or future embedders) don't end up rendering both
|
||||
// systems at once. In the normal Terminal.tsx → store path only one of
|
||||
// the two arrives as true, so this is defensive, not load-bearing.
|
||||
const settings: AutocompleteSettings = {
|
||||
...rawSettings,
|
||||
showGhostText: rawSettings.showPopupMenu ? false : rawSettings.showGhostText,
|
||||
@@ -149,6 +160,31 @@ export function useTerminalAutocomplete(
|
||||
const lastAcceptedCommandRef = useRef<string | null>(null);
|
||||
/** Monotonic counter to invalidate stale async sub-dir fetches */
|
||||
const subDirFetchVersionRef = useRef(0);
|
||||
/**
|
||||
* Keystroke buffer mirroring what the user has typed since the last
|
||||
* prompt boundary (Enter / Ctrl-C / Ctrl-U / cursor movement).
|
||||
*
|
||||
* detectPrompt parses the xterm buffer and can misattribute theme
|
||||
* content — e.g. oh-my-zsh robbyrussell's "➜ ~ " — as user input.
|
||||
* Keeping an independent keystroke log lets getAlignedPrompt snap the
|
||||
* detected userInput back to what was actually typed (and only when
|
||||
* the buffer matches the live line's tail), which in turn keeps
|
||||
* history recording and Tab insertion honest (#806).
|
||||
*/
|
||||
const typedInputBufferRef = useRef<string>("");
|
||||
/**
|
||||
* Whether typedInputBufferRef can be trusted as the full tail of the
|
||||
* current command line. Cleared after any event this append-only buffer
|
||||
* can't follow (history recall via ↑/Ctrl-P, cursor moves, reverse
|
||||
* search, etc.). Reset to true on clean line boundaries — Enter,
|
||||
* Ctrl-C, Ctrl-U — and after we explicitly re-align via
|
||||
* insertSuggestion or a ghost-text accept.
|
||||
*
|
||||
* Without this flag, an Up-arrow-recall workflow would leave the buffer
|
||||
* holding only the post-navigation suffix, and Enter would record that
|
||||
* suffix as a command (pollutes history, misleads future completions).
|
||||
*/
|
||||
const typedBufferReliableRef = useRef<boolean>(true);
|
||||
|
||||
// Preload common specs on first mount (only if enabled)
|
||||
useEffect(() => {
|
||||
@@ -203,6 +239,17 @@ export function useTerminalAutocomplete(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, settings.enabled]);
|
||||
|
||||
// Hide any active ghost when the user turns showGhostText off mid-
|
||||
// session. The fetchSuggestions branch (~L531) already gates new
|
||||
// shows on the flag, but a ghost that was already on screen at toggle
|
||||
// time would otherwise keep sliding around under a disabled setting
|
||||
// until something unrelated called clearState (Codex #815 P2).
|
||||
useEffect(() => {
|
||||
if (!settings.showGhostText) {
|
||||
ghostAddonRef.current?.hide();
|
||||
}
|
||||
}, [settings.showGhostText]);
|
||||
|
||||
/**
|
||||
* Write accepted text to the terminal via callback (no CustomEvent).
|
||||
*/
|
||||
@@ -246,8 +293,12 @@ export function useTerminalAutocomplete(
|
||||
return;
|
||||
}
|
||||
const term = termRef.current;
|
||||
const livePrompt = term ? detectPrompt(term) : null;
|
||||
const activePrompt = livePrompt?.isAtPrompt ? livePrompt : lastPromptRef.current;
|
||||
const { prompt: livePrompt } = getAlignedPrompt(
|
||||
term,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
);
|
||||
const activePrompt = livePrompt.isAtPrompt ? livePrompt : lastPromptRef.current;
|
||||
const activeWord = activePrompt?.isAtPrompt
|
||||
? parseCommandLine(activePrompt.userInput).currentWord
|
||||
: parseCommandLine(item.text).currentWord;
|
||||
@@ -396,8 +447,10 @@ export function useTerminalAutocomplete(
|
||||
const panel = s.subDirPanels[level];
|
||||
if (!panel) return;
|
||||
|
||||
// Get current prompt to know what command prefix to keep (e.g., "cd ")
|
||||
const prompt = detectPrompt(term);
|
||||
// Get current prompt to know what command prefix to keep (e.g., "cd ").
|
||||
// getAlignedPrompt handles robbyrussell-style themes by trimming the
|
||||
// cwd marker out of userInput when the typed buffer is aligned (#806).
|
||||
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
|
||||
// Find the command part (everything before the path argument)
|
||||
@@ -412,9 +465,9 @@ export function useTerminalAutocomplete(
|
||||
: "";
|
||||
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const entryName = quotePrefix || !entry.name.includes(" ")
|
||||
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
|
||||
? entry.name
|
||||
: entry.name.replace(/ /g, "\\ ");
|
||||
: shellEscape(entry.name);
|
||||
const fullPath = panel.dirPath + entryName + suffix;
|
||||
const replacementPath = `${quotePrefix}${fullPath}${quoteSuffix}`;
|
||||
|
||||
@@ -423,7 +476,13 @@ export function useTerminalAutocomplete(
|
||||
const clearSeq = isWindows
|
||||
? "\b".repeat(prompt.userInput.length)
|
||||
: "\x15";
|
||||
writeToTerminal(clearSeq + cmdPrefix + replacementPath);
|
||||
const newCommand = cmdPrefix + replacementPath;
|
||||
writeToTerminal(clearSeq + newCommand);
|
||||
// Sub-dir selection rewrote the whole command line; re-align the
|
||||
// keystroke buffer so the next Enter records the executed command
|
||||
// instead of whatever partial input we had before (P2 from #814).
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
clearState();
|
||||
|
||||
if (entry.type === "directory") {
|
||||
@@ -444,7 +503,7 @@ export function useTerminalAutocomplete(
|
||||
// Capture version at start — if it changes during async work, discard results
|
||||
const version = ++fetchVersionRef.current;
|
||||
|
||||
const prompt = detectPrompt(term);
|
||||
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
||||
lastPromptRef.current = prompt;
|
||||
|
||||
if (!prompt.isAtPrompt || prompt.userInput.length < settingsRef.current.minChars) {
|
||||
@@ -485,16 +544,24 @@ export function useTerminalAutocomplete(
|
||||
|
||||
// Discard stale results: if the user kept typing while getCompletions was running,
|
||||
// the current prompt input will have changed. Re-detect and compare.
|
||||
const currentPrompt = detectPrompt(term);
|
||||
const { prompt: currentPrompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
||||
if (!currentPrompt.isAtPrompt || currentPrompt.userInput !== input) {
|
||||
return; // Input changed — these completions are stale
|
||||
}
|
||||
|
||||
// Ghost text: use the best suggestion
|
||||
if (settingsRef.current.showGhostText && completions.length > 0) {
|
||||
ghostAddonRef.current?.show(completions[0].text, input);
|
||||
} else {
|
||||
ghostAddonRef.current?.hide();
|
||||
// Ghost text: keep the active prediction stable while the user's
|
||||
// input still fits within it. Only swap to a fresh prediction once
|
||||
// the current one no longer matches the typed prefix.
|
||||
if (settingsRef.current.showGhostText) {
|
||||
const ghost = ghostAddonRef.current;
|
||||
const activeSuggestion = ghost?.isActive() ? ghost.getSuggestion() : null;
|
||||
const nextSuggestion = completions.length > 0 ? completions[0].text : null;
|
||||
const ghostDecision = decideGhostSuggestion(activeSuggestion, input, nextSuggestion);
|
||||
if (ghostDecision.type === "show") {
|
||||
ghost?.show(ghostDecision.suggestion, input);
|
||||
} else if (ghostDecision.type === "hide") {
|
||||
ghost?.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Popup
|
||||
@@ -568,23 +635,136 @@ export function useTerminalAutocomplete(
|
||||
if (lastAcceptedCommandRef.current) {
|
||||
recordCommand(lastAcceptedCommandRef.current, hostIdRef.current, hostOsRef.current);
|
||||
} else {
|
||||
// Try real-time detection; fall back to cached prompt
|
||||
const livePrompt = termRef.current ? detectPrompt(termRef.current) : null;
|
||||
const prompt = (livePrompt?.isAtPrompt && livePrompt.userInput.trim())
|
||||
? livePrompt
|
||||
: lastPromptRef.current;
|
||||
if (prompt?.isAtPrompt && prompt.userInput.trim()) {
|
||||
recordCommand(prompt.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
// Require a live prompt before trusting either keystroke buffer
|
||||
// or buffer-based detection — otherwise sudo password Enter
|
||||
// would record the typed password as a command.
|
||||
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
|
||||
termRef.current,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
);
|
||||
if (livePrompt.isAtPrompt) {
|
||||
// alignedTyped is only non-null when the buffer is reliable
|
||||
// AND matches the live line's tail — that single signal
|
||||
// covers both the robbyrussell "~ " case (#806) and the
|
||||
// stale-buffer cases from out-of-band pastes / history
|
||||
// recall (#814 P1/P2). When it's null we fall back to the
|
||||
// reconciled livePrompt.userInput, which for paste-bypass
|
||||
// scenarios lands on pre-PR behavior (no regression).
|
||||
if (alignedTyped && alignedTyped.trim()) {
|
||||
recordCommand(alignedTyped.trim(), hostIdRef.current, hostOsRef.current);
|
||||
} else if (livePrompt.userInput.trim()) {
|
||||
recordCommand(livePrompt.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
} else if (lastPromptRef.current?.isAtPrompt && lastPromptRef.current.userInput.trim()) {
|
||||
// Only fall back to the cached prompt when we have no live
|
||||
// reading at all — guards against recording during interactive
|
||||
// prompts where detectPrompt correctly bails out.
|
||||
recordCommand(lastPromptRef.current.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
}
|
||||
lastAcceptedCommandRef.current = null;
|
||||
}
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = true;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C, Ctrl+U — clear
|
||||
// Ctrl+C, Ctrl+U — clear. These kill the zle line entirely, so the
|
||||
// buffer is once again a true reflection of the (empty) line.
|
||||
if (data === "\x03" || data === "\x15") {
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = true;
|
||||
// Same rationale as the ctrl/escape early returns below: any
|
||||
// previously-accepted suggestion is gone from the line too, so
|
||||
// accept → Ctrl-C → type "foo" → Enter must not log the stale
|
||||
// accepted command via the Enter fast path.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace / DEL: drop the last typed char so the buffer stays aligned
|
||||
// with what the shell actually holds.
|
||||
if (data === "\x7f" || data === "\b") {
|
||||
typedInputBufferRef.current = typedInputBufferRef.current.slice(0, -1);
|
||||
} else if (data === "\x17") {
|
||||
// Ctrl+W: word-erase — kill the trailing whitespace + word.
|
||||
typedInputBufferRef.current = typedInputBufferRef.current.replace(/\s*\S+\s*$/, "");
|
||||
} else if (data.startsWith("\x1b[200~")) {
|
||||
// Bracketed paste: "\x1b[200~...\x1b[201~". The inner bytes are
|
||||
// literal input, so newlines stay on the zle line instead of
|
||||
// executing each segment — meaning we must preserve the whole
|
||||
// content in the buffer, not just the post-final-newline tail
|
||||
// (Codex #814 P2).
|
||||
//
|
||||
// Reliability is *inherited*, not reset: if the buffer was
|
||||
// already aligned with the line (reliable=true), appending this
|
||||
// paste keeps it aligned; if the buffer was unreliable (e.g.
|
||||
// after ↑ recalled a history command so line ≠ buffer), the
|
||||
// paste only extends the tail but the head is still whatever
|
||||
// the shell had, so the buffer stays unreliable. Without this,
|
||||
// a paste-after-recall flow would flip reliability back on and
|
||||
// Enter would record just the pasted suffix as the command
|
||||
// (Codex #814 P1 follow-up).
|
||||
const endIdx = data.indexOf("\x1b[201~");
|
||||
const content = endIdx >= 0
|
||||
? data.slice("\x1b[200~".length, endIdx)
|
||||
: data.slice("\x1b[200~".length);
|
||||
typedInputBufferRef.current += content;
|
||||
// Paste extends the line past whatever was accepted, so the
|
||||
// Enter fast-path must not record the pre-paste accepted
|
||||
// command — mirrors the non-bracketed paste branch below.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
} else if (data.startsWith("\x1b") && data !== "\x1b") {
|
||||
// Cursor-movement / function keys — we lose track of where the
|
||||
// cursor sits relative to our append-only buffer. Mark the
|
||||
// buffer unreliable and drop it; detectPrompt takes over until
|
||||
// the next Enter / Ctrl-C / Ctrl-U.
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = false;
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
typedInputBufferRef.current += data;
|
||||
} else if (data.length > 1 && !data.startsWith("\x1b")) {
|
||||
// Paste chunk. Any \r / \n inside executes the preceding text as
|
||||
// a command in the shell, so keeping the pre-newline portion in
|
||||
// our buffer would leave stale content that a later Enter could
|
||||
// record (Codex #814 P2). Drop everything up to and including
|
||||
// the last terminator and keep only the tail as new content.
|
||||
// Intermediate executed lines aren't synthesized back into
|
||||
// recordCommand here — the onCommandExecuted path in
|
||||
// createXTermRuntime still captures them independently.
|
||||
const lastCR = data.lastIndexOf("\r");
|
||||
const lastLF = data.lastIndexOf("\n");
|
||||
const nlIdx = Math.max(lastCR, lastLF);
|
||||
if (nlIdx >= 0) {
|
||||
typedInputBufferRef.current = data.slice(nlIdx + 1);
|
||||
typedBufferReliableRef.current = true;
|
||||
// The embedded newline flushed any previously-accepted
|
||||
// suggestion too — clearing the cache here prevents the next
|
||||
// Enter from falling into the lastAcceptedCommandRef fast path
|
||||
// and recording that stale command.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
typedInputBufferRef.current += data;
|
||||
} else if (data.length === 1 && data.charCodeAt(0) < 32) {
|
||||
// Any other single control char (Ctrl-A, Ctrl-E, Ctrl-B, Ctrl-F,
|
||||
// Ctrl-R, Ctrl-P, Ctrl-N, ...) moves the cursor or swaps the
|
||||
// line in ways this append-only buffer can't follow. Same story
|
||||
// as escape sequences above — and hide the ghost too, so the
|
||||
// unreliable-accept fallback doesn't pull a stale tail onto a
|
||||
// recalled line (Codex #815 follow-up).
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = false;
|
||||
// Null the fast-path accepted-command cache: accept-then-Ctrl-R
|
||||
// should not let an old accepted command sneak back in via the
|
||||
// Enter fast path after reverse-search picks a different one.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
@@ -593,6 +773,10 @@ export function useTerminalAutocomplete(
|
||||
// since cursor position may have changed, making current suggestions invalid.
|
||||
// Up/Down/Right/Tab are handled by handleKeyEvent; other sequences land here.
|
||||
if (data.startsWith("\x1b") && data !== "\x1b") {
|
||||
// Same fast-path reset as the single-byte ctrl-char branch above —
|
||||
// accept-then-↑/↓ must not record the stale accepted command if
|
||||
// the user then presses Enter on a different recalled line.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
@@ -601,6 +785,20 @@ export function useTerminalAutocomplete(
|
||||
// command is being edited further (e.g., accepted "git status" then added " --short")
|
||||
lastAcceptedCommandRef.current = null;
|
||||
|
||||
// Re-align any visible ghost text to the freshly-updated buffer
|
||||
// immediately. Without this the ghost keeps the tail it captured at
|
||||
// show() time; a fast "type + press →" sequence then pastes the
|
||||
// pre-update tail on top of the new input ("doc" + "cker ls" →
|
||||
// "doccker ls"). Only safe to call when the buffer is reliable —
|
||||
// otherwise its content doesn't correspond to the live line and
|
||||
// adjustToInput would make the ghost lie. Also skip when the user
|
||||
// has turned showGhostText off mid-session: otherwise a ghost that
|
||||
// was active before the toggle would keep moving around under a
|
||||
// setting the user just said to disable (Codex #815 P2).
|
||||
if (typedBufferReliableRef.current && settingsRef.current.showGhostText) {
|
||||
ghostAddonRef.current?.adjustToInput(typedInputBufferRef.current);
|
||||
}
|
||||
|
||||
// Fast typing suppression: if typing faster than threshold, skip this debounce cycle
|
||||
const isFastTyping = timeSinceLastKeystroke < settingsRef.current.fastTypingThresholdMs;
|
||||
|
||||
@@ -654,15 +852,46 @@ export function useTerminalAutocomplete(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Otherwise: accept ghost text
|
||||
if (ghost?.isVisible()) {
|
||||
// Otherwise: accept ghost text. Use isActive(), not isVisible(),
|
||||
// so a fast "type + →" that lands in the hide-until-render gap
|
||||
// still hits this branch and accepts the pending ghost.
|
||||
if (ghost?.isActive()) {
|
||||
e.preventDefault();
|
||||
const ghostText = ghost.getGhostText();
|
||||
const fullSuggestion = ghost.getSuggestion();
|
||||
// When the keystroke buffer is reliable, recompute the tail
|
||||
// against the *live* buffer so a fast "type + →" in the
|
||||
// hide-until-render gap still writes the correct tail. When
|
||||
// it's not reliable (post history-recall / Ctrl-R), we can't
|
||||
// treat empty buffer as "nothing typed" — the line actually
|
||||
// has content we're not tracking — so fall back to the
|
||||
// ghost's own cached tail instead of writing the entire
|
||||
// suggestion onto an already-populated line.
|
||||
let ghostText: string;
|
||||
let newBuffer: string | null;
|
||||
if (typedBufferReliableRef.current) {
|
||||
const live = typedInputBufferRef.current;
|
||||
if (fullSuggestion && fullSuggestion.startsWith(live)) {
|
||||
ghostText = fullSuggestion.substring(live.length);
|
||||
newBuffer = fullSuggestion;
|
||||
} else {
|
||||
ghostText = "";
|
||||
newBuffer = null;
|
||||
}
|
||||
} else {
|
||||
ghostText = ghost.getGhostText();
|
||||
newBuffer = null; // buffer is unreliable; don't flip it back on
|
||||
}
|
||||
if (ghostText) {
|
||||
writeToTerminal(ghostText);
|
||||
lastAcceptedCommandRef.current = ghost.getSuggestion();
|
||||
lastAcceptedCommandRef.current = fullSuggestion;
|
||||
if (newBuffer !== null) {
|
||||
typedInputBufferRef.current = newBuffer;
|
||||
typedBufferReliableRef.current = true;
|
||||
}
|
||||
ghost.hide();
|
||||
clearState();
|
||||
} else {
|
||||
ghost.hide();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -670,18 +899,45 @@ export function useTerminalAutocomplete(
|
||||
|
||||
// Ctrl+Right / Alt+Right (Mac): accept next word
|
||||
if (e.key === "ArrowRight" && (e.ctrlKey || e.altKey) && !e.metaKey && !e.shiftKey) {
|
||||
if (ghost?.isVisible()) {
|
||||
if (ghost?.isActive()) {
|
||||
e.preventDefault();
|
||||
const fullSuggestion = ghost.getSuggestion();
|
||||
if (!fullSuggestion) {
|
||||
ghost.hide();
|
||||
return false;
|
||||
}
|
||||
// Determine the baseline the next word should extend. Reliable
|
||||
// buffer: resync the ghost to the live buffer so getNextWord
|
||||
// operates on the up-to-date tail. Unreliable buffer (post
|
||||
// history-recall / Ctrl-R): don't reanchor to "" — that would
|
||||
// make getNextWord hand back the very first word and the shell
|
||||
// would duplicate leading tokens on top of the recalled line.
|
||||
// Fall back to the ghost's existing cached input instead.
|
||||
if (typedBufferReliableRef.current) {
|
||||
const live = typedInputBufferRef.current;
|
||||
if (fullSuggestion.startsWith(live)) {
|
||||
ghost.show(fullSuggestion, live);
|
||||
} else {
|
||||
ghost.hide();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const base = ghost.getGhostText().length > 0
|
||||
? fullSuggestion.substring(0, fullSuggestion.length - ghost.getGhostText().length)
|
||||
: fullSuggestion;
|
||||
const nextWord = ghost.getNextWord();
|
||||
if (nextWord) {
|
||||
writeToTerminal(nextWord);
|
||||
// Update ghost text to show remaining
|
||||
const fullSuggestion = ghost.getSuggestion();
|
||||
const currentInput = ghost.getGhostText().substring(nextWord.length);
|
||||
if (currentInput && fullSuggestion) {
|
||||
// Rebuild: the new input is old input + nextWord
|
||||
const oldInput = fullSuggestion.substring(0, fullSuggestion.length - ghost.getGhostText().length);
|
||||
ghost.show(fullSuggestion, oldInput + nextWord);
|
||||
// Only extend the buffer if it was already aligned with the
|
||||
// line — otherwise we'd end up with just the appended word,
|
||||
// which the next Enter would then record as the command.
|
||||
if (typedBufferReliableRef.current) {
|
||||
typedInputBufferRef.current += nextWord;
|
||||
}
|
||||
// Shrink the ghost to reflect what's left after the accept.
|
||||
const newInput = base + nextWord;
|
||||
if (fullSuggestion.startsWith(newInput) && fullSuggestion.length > newInput.length) {
|
||||
ghost.show(fullSuggestion, newInput);
|
||||
} else {
|
||||
ghost.hide();
|
||||
}
|
||||
@@ -702,7 +958,7 @@ export function useTerminalAutocomplete(
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
// completion will rewrite the line and the old ghost would mislead.
|
||||
if (ghost?.isVisible()) {
|
||||
if (ghost?.isActive()) {
|
||||
ghost.hide();
|
||||
}
|
||||
}
|
||||
@@ -846,8 +1102,8 @@ export function useTerminalAutocomplete(
|
||||
if (!term) return;
|
||||
|
||||
// Always use real-time prompt detection — lastPromptRef may be stale
|
||||
// if the user typed more characters after suggestions were fetched
|
||||
const prompt = detectPrompt(term);
|
||||
// if the user typed more characters after suggestions were fetched.
|
||||
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
|
||||
// If suggestion starts with the current input, insert only the remaining part.
|
||||
@@ -871,6 +1127,18 @@ export function useTerminalAutocomplete(
|
||||
writeToTerminal(payload);
|
||||
}
|
||||
|
||||
// Keystroke buffer now reflects the accepted text (either extended by
|
||||
// the insertion suffix, or wholesale replaced by the fuzzy-match path
|
||||
// that emits Ctrl-U first). Re-aligning it here keeps the subsequent
|
||||
// Enter-record honest, and flips reliability back on since we know
|
||||
// the line content exactly.
|
||||
if (execute) {
|
||||
typedInputBufferRef.current = "";
|
||||
} else {
|
||||
typedInputBufferRef.current = suggestion.text;
|
||||
}
|
||||
typedBufferReliableRef.current = true;
|
||||
|
||||
// Track accepted command for accurate history recording on fast Enter
|
||||
if (!execute) {
|
||||
lastAcceptedCommandRef.current = suggestion.text;
|
||||
|
||||
@@ -485,6 +485,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// Wrap for this terminal only, after broadcasting
|
||||
const snippetIsMultiLine = snippetData.includes("\n");
|
||||
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
|
||||
// Notify autocomplete with the final (possibly bracket-wrapped)
|
||||
// bytes so its keystroke buffer can tell literal multi-line
|
||||
// paste ("\x1b[200~...\x1b[201~") from the non-bracketed path
|
||||
// where each \n executes an intermediate command (#814 P2).
|
||||
ctx.onAutocompleteInput?.(snippetData);
|
||||
ctx.terminalBackend.writeToSession(id, snippetData);
|
||||
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
|
||||
const cmd = snippet.command.trim();
|
||||
@@ -525,8 +530,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
const rawData = normalizeLineEndings(text);
|
||||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||||
? wrapBracketedPaste(rawData)
|
||||
: rawData;
|
||||
// Notify autocomplete with the final bytes so bracketed
|
||||
// pastes preserve their inner newlines as literal input.
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
@@ -537,8 +547,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const selection = term.getSelection();
|
||||
const id = ctx.sessionRef.current;
|
||||
if (selection && id) {
|
||||
let data = normalizeLineEndings(selection);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
const rawData = normalizeLineEndings(selection);
|
||||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||||
? wrapBracketedPaste(rawData)
|
||||
: rawData;
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
@@ -572,8 +585,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && ctx.sessionRef.current) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
const rawData = normalizeLineEndings(text);
|
||||
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
|
||||
? wrapBracketedPaste(rawData)
|
||||
: rawData;
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,17 @@ module.exports = {
|
||||
appId: 'com.netcatty.app',
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
// Platform-split icons (#813):
|
||||
// - public/icon.png keeps Apple's HIG grid margin so the rendered
|
||||
// squircle sits at ~88% of the PNG canvas. macOS needs this —
|
||||
// the dock renders icons with its own rounding/shadow and most
|
||||
// third-party apps (#803) leave that grid margin alone so the
|
||||
// squircle lines up with neighbors.
|
||||
// - public/icon-win.png uses a tight-crop viewBox so the squircle
|
||||
// fills 100% of the PNG. Windows / Linux taskbars render icons
|
||||
// full-bleed, so the Apple margin showed up as visible padding,
|
||||
// making the app icon look smaller than other apps in taskbar /
|
||||
// Start menu / desktop shortcuts.
|
||||
icon: 'public/icon.png',
|
||||
// npmRebuild must stay enabled for macOS and Windows builds — without it,
|
||||
// node-pty's native module is not recompiled for the Electron ABI, causing
|
||||
@@ -84,6 +95,7 @@ module.exports = {
|
||||
]
|
||||
},
|
||||
win: {
|
||||
icon: 'public/icon-win.png',
|
||||
target: [
|
||||
{
|
||||
target: 'nsis',
|
||||
@@ -108,6 +120,10 @@ module.exports = {
|
||||
shortcutName: 'Netcatty'
|
||||
},
|
||||
linux: {
|
||||
// Linux desktop icons render full-bleed like Windows — use the
|
||||
// tight-crop source so the app icon doesn't look padded in KDE /
|
||||
// GNOME launchers or AppImage integrations.
|
||||
icon: 'public/icon-win.png',
|
||||
target: ['AppImage', 'deb', 'rpm'],
|
||||
category: 'Development'
|
||||
},
|
||||
|
||||
@@ -10,9 +10,29 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("crypto");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const iconv = require("iconv-lite");
|
||||
const { stripAnsi } = require("./shellUtils.cjs");
|
||||
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
|
||||
|
||||
// Build a stateful decoder for a full exec call. Serial data events can
|
||||
// split multi-byte characters across chunks (very common on GBK/GB18030
|
||||
// consoles), and a stateless iconv.decode per chunk would emit
|
||||
// replacement bytes for the leading half. StringDecoder and
|
||||
// iconv.getDecoder both preserve partial-byte state across write() calls
|
||||
// and flush any trailing bytes on end(), which is what we need.
|
||||
function createStatefulDecoder(encoding) {
|
||||
const enc = encoding || "utf8";
|
||||
if (Buffer.isEncoding(enc)) {
|
||||
return new StringDecoder(enc);
|
||||
}
|
||||
try {
|
||||
return iconv.getDecoder(enc);
|
||||
} catch {
|
||||
return new StringDecoder("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function detectShellKind(shellPath, platform = process.platform) {
|
||||
return classifyLocalShellType(shellPath, platform);
|
||||
}
|
||||
@@ -943,6 +963,7 @@ function execViaRawPty(serialPort, command, options) {
|
||||
let overallTimer = null;
|
||||
let idleTimer = null;
|
||||
const cleanupFns = [];
|
||||
const decoder = createStatefulDecoder(encoding);
|
||||
|
||||
function safeWrite(data) {
|
||||
try {
|
||||
@@ -962,7 +983,12 @@ function execViaRawPty(serialPort, command, options) {
|
||||
trackForCancellation.delete(cancelKey);
|
||||
}
|
||||
|
||||
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
|
||||
// Flush any bytes the decoder is still holding (e.g. the leading
|
||||
// half of a multi-byte char that arrived right before finish).
|
||||
let tail = "";
|
||||
try { tail = decoder.end() || ""; } catch { /* ignore */ }
|
||||
const complete = (stdout || "") + tail;
|
||||
let cleaned = stripAnsi(complete).replace(/\r/g, "");
|
||||
|
||||
// Strip echoed command from the beginning of output.
|
||||
// Network devices typically echo back the typed command on the first line,
|
||||
@@ -1011,8 +1037,11 @@ function execViaRawPty(serialPort, command, options) {
|
||||
const MAX_OUTPUT_BYTES = 512 * 1024; // 512 KB
|
||||
|
||||
const onData = (data) => {
|
||||
// latin1 for serial ports (matches terminalBridge.cjs decoder); utf8 for SSH PTY streams.
|
||||
const chunk = typeof data === "string" ? data : data.toString(encoding);
|
||||
// Encoding follows the session: utf8 for SSH PTY streams, whatever the
|
||||
// user picked for serial (utf-8/gb18030/...). The decoder is stateful
|
||||
// so multi-byte characters split across chunks get stitched back
|
||||
// together instead of emitting replacement bytes.
|
||||
const chunk = typeof data === "string" ? data : decoder.write(data);
|
||||
chunkCount++;
|
||||
// Cancel the no-response fallback on first data
|
||||
if (noResponseTimer) {
|
||||
|
||||
@@ -182,7 +182,10 @@ async function getShellEnv() {
|
||||
|
||||
// On macOS/Linux, spawn a login shell to capture the real environment.
|
||||
try {
|
||||
const shell = process.env.SHELL || "/bin/zsh";
|
||||
let shell = process.env.SHELL || "/bin/zsh";
|
||||
if (!path.isAbsolute(shell) || !existsSync(shell)) {
|
||||
shell = "/bin/zsh";
|
||||
}
|
||||
const envOutput = execFileSync(shell, ['-ilc', 'env'], {
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
|
||||
@@ -9,6 +9,7 @@ const https = require("node:https");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const { URL } = require("node:url");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { spawn, execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const { existsSync } = fs;
|
||||
@@ -1854,7 +1855,7 @@ function registerHandlers(ipcMain) {
|
||||
try {
|
||||
const shellEnv = await getShellEnv();
|
||||
const codexCliPath = resolveCliFromPath("codex", shellEnv) || "codex";
|
||||
const sessionId = `codex_login_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const sessionId = `codex_login_${randomUUID()}`;
|
||||
const child = spawn(codexCliPath, ["login"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: shellEnv,
|
||||
|
||||
@@ -589,12 +589,21 @@ function createTray() {
|
||||
const resolvedIconPath = resolveTrayIconPath();
|
||||
if (resolvedIconPath) {
|
||||
trayIcon = nativeImage.createFromPath(resolvedIconPath);
|
||||
// Resize for tray (16x16 on most platforms, 22x22 on some Linux)
|
||||
if (process.platform === "darwin") {
|
||||
trayIcon = trayIcon.resize({ width: 16, height: 16 });
|
||||
trayIcon.setTemplateImage(true);
|
||||
} else {
|
||||
trayIcon = trayIcon.resize({ width: 16, height: 16 });
|
||||
// Windows/Linux: attach the @2x representation so the OS can pick
|
||||
// the right pixel size per DPI scale. Force-resizing to 16x16 here
|
||||
// produces blurry icons on HiDPI displays where the tray slot is
|
||||
// rendered larger than 16px.
|
||||
const hiDpiPath = resolvedIconPath.replace(/\.png$/i, "@2x.png");
|
||||
if (fs.existsSync(hiDpiPath)) {
|
||||
trayIcon.addRepresentation({
|
||||
scaleFactor: 2,
|
||||
buffer: fs.readFileSync(hiDpiPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ function createElectronStub() {
|
||||
return this;
|
||||
},
|
||||
setTemplateImage() {},
|
||||
addRepresentation() {},
|
||||
};
|
||||
},
|
||||
createEmpty() {
|
||||
|
||||
@@ -10,6 +10,7 @@ const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
|
||||
const GOOGLE_DRIVE_API = "https://www.googleapis.com/drive/v3";
|
||||
const GOOGLE_DRIVE_UPLOAD_API = "https://www.googleapis.com/upload/drive/v3";
|
||||
const DEFAULT_SYNC_FILE_NAME = "netcatty-vault.json";
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const isNonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
|
||||
|
||||
@@ -251,7 +252,7 @@ function registerHandlers(ipcMain, electronModule) {
|
||||
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
|
||||
if (!syncedFile) throw new Error("Missing syncedFile");
|
||||
|
||||
const boundary = `----netcatty_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const boundary = `----netcatty_${randomUUID()}`;
|
||||
const metadata = JSON.stringify({
|
||||
name: fileName,
|
||||
parents: ["appDataFolder"],
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
// Keyboard-interactive authentication pending requests
|
||||
// Map of requestId -> { finishCallback, webContentsId, sessionId, createdAt, timeoutId }
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const keyboardInteractiveRequests = new Map();
|
||||
|
||||
// TTL for abandoned requests (5 minutes)
|
||||
@@ -15,7 +17,7 @@ const REQUEST_TTL_MS = 5 * 60 * 1000;
|
||||
* Generate a unique request ID for keyboard-interactive requests
|
||||
*/
|
||||
function generateRequestId(prefix = 'ki') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${prefix}-${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
// Passphrase request pending map
|
||||
// Map of requestId -> { resolveCallback, rejectCallback, webContentsId, keyPath, createdAt, timeoutId }
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const passphraseRequests = new Map();
|
||||
|
||||
// TTL for abandoned requests (2 minutes)
|
||||
@@ -15,7 +17,7 @@ const REQUEST_TTL_MS = 2 * 60 * 1000;
|
||||
* Generate a unique request ID for passphrase requests
|
||||
*/
|
||||
function generateRequestId(prefix = 'pp') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${prefix}-${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { pipeline } = require("node:stream/promises");
|
||||
const { TextDecoder } = require("node:util");
|
||||
const SftpClient = require("ssh2-sftp-client");
|
||||
@@ -546,7 +547,7 @@ function buildStagedRemotePath(remotePath) {
|
||||
const dir = lastSeparatorIndex >= 0 ? remotePath.slice(0, lastSeparatorIndex + 1) : "";
|
||||
const baseName = lastSeparatorIndex >= 0 ? remotePath.slice(lastSeparatorIndex + 1) : remotePath;
|
||||
const safeBaseName = baseName || "upload";
|
||||
const stagedName = `.netcatty-upload-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}-${safeBaseName}.part`;
|
||||
const stagedName = `.netcatty-upload-${randomUUID().slice(0, 8)}-${safeBaseName}.part`;
|
||||
return dir ? `${dir}${stagedName}` : stagedName;
|
||||
}
|
||||
|
||||
@@ -555,7 +556,7 @@ function buildBackupRemotePath(remotePath) {
|
||||
const dir = lastSeparatorIndex >= 0 ? remotePath.slice(0, lastSeparatorIndex + 1) : "";
|
||||
const baseName = lastSeparatorIndex >= 0 ? remotePath.slice(lastSeparatorIndex + 1) : remotePath;
|
||||
const safeBaseName = baseName || "upload";
|
||||
const backupName = `.netcatty-backup-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}-${safeBaseName}.bak`;
|
||||
const backupName = `.netcatty-backup-${randomUUID().slice(0, 8)}-${safeBaseName}.bak`;
|
||||
return dir ? `${dir}${backupName}` : backupName;
|
||||
}
|
||||
|
||||
@@ -794,7 +795,7 @@ async function openSftpForSession(_event, payload) {
|
||||
|
||||
throwIfAborted(payload?.abortSignal);
|
||||
const { sshClient } = ensureRemoteSftpSupport(sessionId);
|
||||
const sftpId = `${sessionId}-sftp-${Math.random().toString(16).slice(2, 10)}`;
|
||||
const sftpId = `${sessionId}-sftp-${randomUUID()}`;
|
||||
const client = createSessionBackedSftpClient(sessionId, sshClient);
|
||||
try {
|
||||
await requireSftpChannel(client, {
|
||||
@@ -1359,7 +1360,7 @@ async function connectSudoSftp(client, password) {
|
||||
*/
|
||||
async function openSftp(event, options) {
|
||||
const client = new SftpClient();
|
||||
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
|
||||
const connId = options.sessionId || randomUUID();
|
||||
|
||||
// Get default keys early to use for both chain and target
|
||||
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
|
||||
|
||||
@@ -204,8 +204,9 @@ function detectPowerShell() {
|
||||
}
|
||||
|
||||
// Fallback: well-known path.
|
||||
const systemRoot = process.env.SystemRoot || "C:\\Windows";
|
||||
const fallback = path.join(
|
||||
process.env.SystemRoot || "C:\\Windows",
|
||||
systemRoot,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
@@ -266,7 +267,7 @@ function detectPwsh() {
|
||||
"7",
|
||||
"pwsh.exe",
|
||||
);
|
||||
if (fs.existsSync(fallback)) {
|
||||
if (fs.existsSync(fallback) && fallback.toLowerCase().includes("powershell")) {
|
||||
return {
|
||||
id: "pwsh",
|
||||
name: "PowerShell 7",
|
||||
@@ -286,12 +287,14 @@ function detectPwsh() {
|
||||
* @returns {object[]} Array of DiscoveredShell objects (may be empty).
|
||||
*/
|
||||
function detectWslDistros() {
|
||||
const wslExe = path.join(
|
||||
process.env.SystemRoot || "C:\\Windows",
|
||||
"System32",
|
||||
"wsl.exe",
|
||||
);
|
||||
if (!fs.existsSync(wslExe)) return [];
|
||||
const systemRoot = process.env.SystemRoot || "C:\\Windows";
|
||||
const wslExe = path.join(systemRoot, "System32", "wsl.exe");
|
||||
if (
|
||||
!fs.existsSync(wslExe) ||
|
||||
!path.dirname(wslExe).toLowerCase().endsWith("system32")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const distros = [];
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
|
||||
|
||||
async function readFileNoFollow(filePath) {
|
||||
const lstat = await fs.promises.lstat(filePath);
|
||||
if (!lstat.isFile() && !lstat.isSymbolicLink()) return null;
|
||||
const fd = await fs.promises.open(filePath, "r", 0o0);
|
||||
try {
|
||||
return await fs.promises.readFile(fd, { encoding: "utf8" });
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if file content looks like an SSH private key.
|
||||
* Rejects non-key files that happen to match the id_* filename pattern.
|
||||
@@ -107,9 +118,8 @@ async function findDefaultPrivateKey() {
|
||||
for (const name of sorted) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) continue; // Skip directories, FIFOs, sockets, etc.
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const privateKey = await readFileNoFollow(keyPath);
|
||||
if (!privateKey) continue;
|
||||
if (!looksLikePrivateKey(privateKey)) continue;
|
||||
if (isKeyEncrypted(privateKey)) continue;
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
@@ -144,9 +154,8 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
const promises = sorted.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) return null;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const privateKey = await readFileNoFollow(keyPath);
|
||||
if (!privateKey) return null;
|
||||
if (!looksLikePrivateKey(privateKey)) return null;
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
@@ -659,4 +668,5 @@ module.exports = {
|
||||
applyAuthToConnOpts,
|
||||
safeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
readFileNoFollow,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
const net = require("node:net");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const os = require("node:os");
|
||||
const crypto = require("node:crypto");
|
||||
const { exec } = require("node:child_process");
|
||||
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
@@ -21,6 +23,7 @@ const {
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
getSshAgentSocket,
|
||||
readFileNoFollow,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
@@ -122,9 +125,8 @@ async function findDefaultPrivateKey() {
|
||||
for (const name of sorted) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) continue;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const privateKey = await readFileNoFollow(keyPath);
|
||||
if (!privateKey) continue;
|
||||
if (!looksLikePrivateKey(privateKey)) {
|
||||
log("Skipping non-key file", { keyPath, keyName: name });
|
||||
continue;
|
||||
@@ -168,9 +170,8 @@ async function findAllDefaultPrivateKeys() {
|
||||
const promises = sorted.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) return null;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const privateKey = await readFileNoFollow(keyPath);
|
||||
if (!privateKey) return null;
|
||||
if (!looksLikePrivateKey(privateKey)) {
|
||||
log("Skipping non-key file", { keyPath, keyName: name });
|
||||
return null;
|
||||
@@ -251,6 +252,19 @@ const log = (msg, data) => {
|
||||
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
|
||||
};
|
||||
|
||||
// FIPS-enabled OpenSSL builds disable MD5. Feature-detect once so the legacy
|
||||
// algorithm list can skip hmac-md5 on those builds — ssh2 validates exact
|
||||
// algorithm lists strictly and would otherwise throw "Unsupported algorithm"
|
||||
// before the SSH handshake even starts.
|
||||
let _md5Supported = null;
|
||||
function md5Supported() {
|
||||
if (_md5Supported === null) {
|
||||
try { _md5Supported = crypto.getHashes().includes("md5"); }
|
||||
catch { _md5Supported = false; }
|
||||
}
|
||||
return _md5Supported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration.
|
||||
* When legacyEnabled is true, legacy algorithms are appended to each list
|
||||
@@ -285,6 +299,32 @@ function buildAlgorithms(legacyEnabled) {
|
||||
'rsa-sha2-512', 'rsa-sha2-256',
|
||||
'ssh-rsa', 'ssh-dss',
|
||||
];
|
||||
// Legacy HMACs — required by very old servers (e.g. FreeBSD 6.1 OpenSSH
|
||||
// ~2006, issue #807). Without hmac-sha1/md5 in the offered list, the
|
||||
// handshake exchange-hash MAC never agrees and the host-key signature
|
||||
// verification that depends on it fails with
|
||||
// "Handshake failed: signature verification failed" — which looks like
|
||||
// a host-key problem but is really a MAC negotiation mismatch.
|
||||
//
|
||||
// hmac-md5 is only appended when the local OpenSSL build actually
|
||||
// supports MD5. FIPS-enabled Node builds disable MD5 entirely, and
|
||||
// ssh2 strictly validates exact algorithm lists — listing an unavailable
|
||||
// algorithm would throw "Unsupported algorithm" before any SSH
|
||||
// negotiation, turning the legacy toggle into a hard failure for FIPS
|
||||
// users. hmac-sha1 is allowed for HMAC even under FIPS 140-2 so it
|
||||
// stays unconditionally.
|
||||
// hmac-sha1-etm@openssh.com is in ssh2's default MAC set — keep it so
|
||||
// hosts that only accept EtM SHA-1 MACs don't regress to "no matching
|
||||
// C->S MAC" when legacy mode replaces the default list.
|
||||
algorithms.hmac = [
|
||||
'hmac-sha2-256-etm@openssh.com', 'hmac-sha2-512-etm@openssh.com',
|
||||
'hmac-sha2-256', 'hmac-sha2-512',
|
||||
'hmac-sha1-etm@openssh.com',
|
||||
'hmac-sha1',
|
||||
];
|
||||
if (md5Supported()) {
|
||||
algorithms.hmac.push('hmac-md5');
|
||||
}
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
@@ -648,9 +688,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
* Start an SSH session
|
||||
*/
|
||||
async function startSSHSession(event, options) {
|
||||
const sessionId =
|
||||
options.sessionId ||
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
|
||||
const cols = options.cols || 80;
|
||||
const rows = options.rows || 24;
|
||||
@@ -1558,7 +1596,7 @@ async function execCommand(event, payload) {
|
||||
const baseTimeoutMs = payload.timeout || 10000;
|
||||
const timeoutMs = enableKeyboardInteractive ? Math.max(baseTimeoutMs, 120000) : baseTimeoutMs;
|
||||
const sender = event.sender;
|
||||
const sessionId = payload.sessionId || `exec-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const sessionId = payload.sessionId || randomUUID();
|
||||
const defaultKeys = enableKeyboardInteractive ? await findAllDefaultPrivateKeysFromHelper() : [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
const iconv = require("iconv-lite");
|
||||
const ptyProcessTree = require("./ptyProcessTree.cjs");
|
||||
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
@@ -22,18 +24,18 @@ const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
let sessions = null;
|
||||
let electronModule = null;
|
||||
|
||||
// Map user-facing charset names to Node.js StringDecoder/Buffer encoding names.
|
||||
// Falls back to utf8 for unrecognized charsets (StringDecoder only supports a
|
||||
// small set; for CJK encodings like GB18030/Big5 we'd need iconv-lite, which
|
||||
// is out of scope for this change — utf8 is still the safer default).
|
||||
function charsetToNodeEncoding(charset) {
|
||||
if (!charset) return 'utf8';
|
||||
const normalized = String(charset).trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (['utf8', 'utf-8'].includes(normalized)) return 'utf8';
|
||||
if (['latin1', 'iso88591', 'iso-8859-1', 'binary'].includes(normalized)) return 'latin1';
|
||||
if (normalized === 'ascii') return 'ascii';
|
||||
if (['utf16le', 'ucs2'].includes(normalized)) return 'utf16le';
|
||||
return 'utf8';
|
||||
// Normalize user-facing charset names into an iconv-lite encoding identifier.
|
||||
// iconv-lite accepts a wide range of aliases directly ("utf-8", "gbk", etc.),
|
||||
// so mostly this just lowercases + collapses non-alphanumerics and maps a few
|
||||
// obvious GB* variants to gb18030 which is the superset we ship the encoding
|
||||
// switcher with. Anything iconv doesn't recognize falls back to utf-8.
|
||||
function normalizeTerminalEncoding(charset) {
|
||||
if (!charset) return 'utf-8';
|
||||
const raw = String(charset).trim().toLowerCase();
|
||||
const normalized = raw.replace(/[^a-z0-9]/g, '');
|
||||
if (['utf8', 'utf-8'].includes(normalized)) return 'utf-8';
|
||||
if (normalized === 'gb18030' || normalized === 'gbk' || normalized === 'gb2312') return 'gb18030';
|
||||
return iconv.encodingExists(raw) ? raw : 'utf-8';
|
||||
}
|
||||
|
||||
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
|
||||
@@ -151,8 +153,9 @@ function findExecutable(name) {
|
||||
console.warn(`Could not find ${name} via where.exe:`, err.message);
|
||||
}
|
||||
|
||||
// Fallback to common locations
|
||||
const path = require("node:path");
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return name;
|
||||
|
||||
const commonPaths = [];
|
||||
|
||||
if (name === "pwsh") {
|
||||
@@ -250,9 +253,7 @@ const applyLocaleDefaults = (env) => {
|
||||
* Start a local terminal session
|
||||
*/
|
||||
function startLocalSession(event, payload) {
|
||||
const sessionId =
|
||||
payload?.sessionId ||
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const sessionId = payload?.sessionId || randomUUID();
|
||||
const defaultShell = getDefaultLocalShell();
|
||||
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
|
||||
let resolvedShell = payload?.shell;
|
||||
@@ -401,9 +402,7 @@ function startLocalSession(event, payload) {
|
||||
* Start a Telnet session using native Node.js net module
|
||||
*/
|
||||
async function startTelnetSession(event, options) {
|
||||
const sessionId =
|
||||
options.sessionId ||
|
||||
`telnet-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
|
||||
const hostname = options.hostname;
|
||||
const port = options.port || 23;
|
||||
@@ -556,6 +555,8 @@ async function startTelnetSession(event, options) {
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
encoding: initialTelnetEncoding,
|
||||
decoderRef: telnetDecoderRef,
|
||||
};
|
||||
session.flushPendingData = flushTelnet;
|
||||
sessions.set(sessionId, session);
|
||||
@@ -574,7 +575,11 @@ async function startTelnetSession(event, options) {
|
||||
resolve({ sessionId });
|
||||
});
|
||||
|
||||
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
|
||||
// Wrap the iconv decoder in a mutable ref so the encoding switcher
|
||||
// (setSessionEncoding IPC) can swap in a fresh decoder mid-session
|
||||
// without having to rewrite the closures below.
|
||||
const initialTelnetEncoding = normalizeTerminalEncoding(options.charset);
|
||||
const telnetDecoderRef = { current: iconv.getDecoder(initialTelnetEncoding) };
|
||||
|
||||
const telnetWebContentsId = event.sender.id;
|
||||
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyBuffer((data) => {
|
||||
@@ -585,7 +590,7 @@ async function startTelnetSession(event, options) {
|
||||
const telnetZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const decoded = telnetDecoder.write(buf);
|
||||
const decoded = telnetDecoderRef.current.write(buf);
|
||||
if (!decoded) return;
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) trackSessionIdlePrompt(session, decoded);
|
||||
@@ -681,9 +686,7 @@ async function startTelnetSession(event, options) {
|
||||
* Start a Mosh session using system mosh-client
|
||||
*/
|
||||
async function startMoshSession(event, options) {
|
||||
const sessionId =
|
||||
options.sessionId ||
|
||||
`mosh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
|
||||
const cols = options.cols || 80;
|
||||
const rows = options.rows || 24;
|
||||
@@ -847,9 +850,7 @@ async function listSerialPorts() {
|
||||
* Note: SerialPort library can open PTY devices directly, they just won't appear in list()
|
||||
*/
|
||||
async function startSerialSession(event, options) {
|
||||
const sessionId =
|
||||
options.sessionId ||
|
||||
`serial-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const sessionId = options.sessionId || randomUUID();
|
||||
|
||||
const portPath = options.path;
|
||||
const baudRate = options.baudRate || 115200;
|
||||
@@ -883,15 +884,19 @@ async function startSerialSession(event, options) {
|
||||
|
||||
console.log(`[Serial] Connected to ${portPath}`);
|
||||
|
||||
const serialEncoding = charsetToNodeEncoding(options.charset);
|
||||
const serialDecoder = new StringDecoder(serialEncoding);
|
||||
const initialSerialEncoding = normalizeTerminalEncoding(options.charset);
|
||||
const serialDecoderRef = { current: iconv.getDecoder(initialSerialEncoding) };
|
||||
|
||||
const session = {
|
||||
serialPort,
|
||||
type: 'serial',
|
||||
protocol: 'serial',
|
||||
shellKind: 'raw',
|
||||
serialEncoding,
|
||||
encoding: initialSerialEncoding,
|
||||
// Kept for backward compatibility with aiBridge / mcpServerBridge
|
||||
// which read session.serialEncoding for exec calls.
|
||||
serialEncoding: initialSerialEncoding,
|
||||
decoderRef: serialDecoderRef,
|
||||
webContentsId: event.sender.id,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
@@ -910,7 +915,7 @@ async function startSerialSession(event, options) {
|
||||
const serialZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const decoded = serialDecoder.write(buf);
|
||||
const decoded = serialDecoderRef.current.write(buf);
|
||||
if (!decoded) return;
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
@@ -1055,6 +1060,32 @@ function closeSession(event, payload) {
|
||||
sessions.delete(payload.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set terminal decoder encoding for an active telnet or serial session.
|
||||
* SSH sessions are handled by sshBridge's own setEncoding IPC — this one
|
||||
* only responds to sessions that carry a decoderRef (telnet + serial).
|
||||
*/
|
||||
function setSessionEncoding(_event, { sessionId, encoding }) {
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session || !session.decoderRef) {
|
||||
return { ok: false, encoding: encoding || 'utf-8' };
|
||||
}
|
||||
const enc = normalizeTerminalEncoding(encoding);
|
||||
if (!iconv.encodingExists(enc)) {
|
||||
return { ok: false, encoding: enc };
|
||||
}
|
||||
session.encoding = enc;
|
||||
// Keep serialEncoding mirror in sync so aiBridge / mcpServerBridge exec
|
||||
// calls pick up the new encoding too.
|
||||
if (session.type === 'serial') {
|
||||
session.serialEncoding = enc;
|
||||
}
|
||||
// iconv stateful decoders carry partial-byte state from the previous
|
||||
// encoding, so swap in a fresh decoder rather than reconfiguring.
|
||||
session.decoderRef.current = iconv.getDecoder(enc);
|
||||
return { ok: true, encoding: enc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for terminal operations
|
||||
*/
|
||||
@@ -1067,6 +1098,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
|
||||
ipcMain.handle("netcatty:local:validatePath", validatePath);
|
||||
ipcMain.handle("netcatty:shells:discover", () => discoverShells());
|
||||
ipcMain.handle("netcatty:terminal:setEncoding", setSessionEncoding);
|
||||
ipcMain.on("netcatty:write", writeToSession);
|
||||
ipcMain.on("netcatty:resize", resizeSession);
|
||||
ipcMain.on("netcatty:close", closeSession);
|
||||
|
||||
@@ -1184,8 +1184,76 @@ if (!gotLock) {
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
// Quit guard state:
|
||||
// - quitConfirmed: once true, before-quit falls through without re-checking.
|
||||
// Set right before we call app.quit() after a successful dirty-editor check,
|
||||
// so the re-entered before-quit doesn't loop back into another check.
|
||||
// - quitGuardChannelBusy: prevents a second check from being started while the
|
||||
// first round-trip is still in flight.
|
||||
// Note: both are intentionally NOT reset on the dirty=true path — if the user
|
||||
// cancels quit to save, a subsequent Cmd+Q re-enters with quitConfirmed=false
|
||||
// and quitGuardChannelBusy=false (reset in the once/timeout handlers), which
|
||||
// kicks off a fresh check as expected.
|
||||
let quitGuardChannelBusy = false;
|
||||
let quitConfirmed = false;
|
||||
|
||||
// 5s timeout: long enough for the renderer to show a toast before reporting
|
||||
// back, short enough that a hung renderer doesn't strand the app forever.
|
||||
const QUIT_GUARD_TIMEOUT_MS = 5000;
|
||||
|
||||
// Commit the window manager to "we're quitting" state. Must only run once
|
||||
// we've decided to actually proceed — if we set it unconditionally on every
|
||||
// before-quit, a dirty-cancelled quit leaves isQuitting=true and changes
|
||||
// later window-close behavior (e.g. close-to-tray hooks that gate on
|
||||
// !isQuitting would stop firing).
|
||||
const commitQuit = () => {
|
||||
getWindowManager().setIsQuitting(true);
|
||||
quitConfirmed = true;
|
||||
app.quit();
|
||||
};
|
||||
|
||||
app.on("before-quit", (event) => {
|
||||
// Fast path: we've already confirmed the quit once (commitQuit ran) and
|
||||
// app.quit() re-fired before-quit. Let it through.
|
||||
if (quitConfirmed) return;
|
||||
|
||||
// A check is already in flight — swallow this event; the in-flight handler
|
||||
// will issue commitQuit() when it completes if appropriate.
|
||||
if (quitGuardChannelBusy) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const { ipcMain: _ipcMain } = electronModule;
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
// No window — nothing to check; commit to quit directly.
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
commitQuit();
|
||||
return;
|
||||
}
|
||||
|
||||
quitGuardChannelBusy = true;
|
||||
event.preventDefault();
|
||||
win.webContents.send("app:query-dirty-editors");
|
||||
|
||||
// Timeout fallback: if the renderer never replies (crash, unhandled
|
||||
// exception in the listener, etc.) we'd otherwise be stuck with
|
||||
// quitGuardChannelBusy=true and the app un-quittable.
|
||||
const timeoutId = setTimeout(() => {
|
||||
_ipcMain.removeAllListeners("app:dirty-editors-result");
|
||||
quitGuardChannelBusy = false;
|
||||
commitQuit();
|
||||
}, QUIT_GUARD_TIMEOUT_MS);
|
||||
|
||||
_ipcMain.once("app:dirty-editors-result", (_evt, { hasDirty }) => {
|
||||
clearTimeout(timeoutId);
|
||||
quitGuardChannelBusy = false;
|
||||
if (!hasDirty) {
|
||||
commitQuit();
|
||||
}
|
||||
// If hasDirty === true the renderer has shown a toast; stay put. Do not
|
||||
// touch isQuitting so tray/close-to-tray gating keeps working.
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { ipcRenderer, contextBridge, webUtils } = require("electron");
|
||||
const os = require("node:os");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const dataListeners = new Map();
|
||||
const exitListeners = new Map();
|
||||
@@ -598,8 +599,14 @@ const api = {
|
||||
closeSession: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:close", { sessionId });
|
||||
},
|
||||
setSessionEncoding: (sessionId, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding }),
|
||||
setSessionEncoding: async (sessionId, encoding) => {
|
||||
// Try the SSH handler first; it returns { ok: false } for non-SSH
|
||||
// sessions (no session.stream). Telnet and serial sessions fall
|
||||
// through to terminalBridge's handler.
|
||||
const ssh = await ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding });
|
||||
if (ssh?.ok) return ssh;
|
||||
return ipcRenderer.invoke("netcatty:terminal:setEncoding", { sessionId, encoding });
|
||||
},
|
||||
onZmodemEvent: (sessionId, cb) => {
|
||||
if (!zmodemListeners.has(sessionId)) zmodemListeners.set(sessionId, new Set());
|
||||
zmodemListeners.get(sessionId).add(cb);
|
||||
@@ -894,6 +901,16 @@ const api = {
|
||||
|
||||
// Tell main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady: () => ipcRenderer.send("netcatty:renderer:ready"),
|
||||
|
||||
// Quit guard: main process asks whether any editor tabs have unsaved changes.
|
||||
// Returns an unsubscribe function so React effects can clean up on unmount.
|
||||
onCheckDirtyEditors: (listener) => {
|
||||
const handler = () => listener();
|
||||
ipcRenderer.on("app:query-dirty-editors", handler);
|
||||
return () => ipcRenderer.removeListener("app:query-dirty-editors", handler);
|
||||
},
|
||||
// Renderer reports the dirty-check result back to the main process.
|
||||
reportDirtyEditorsResult: (hasDirty) => ipcRenderer.send("app:dirty-editors-result", { hasDirty }),
|
||||
|
||||
// Port Forwarding API
|
||||
startPortForward: async (options) => {
|
||||
@@ -928,7 +945,7 @@ const api = {
|
||||
},
|
||||
// Chain progress listener for jump host connections
|
||||
onChainProgress: (cb) => {
|
||||
const id = Date.now().toString() + Math.random().toString(16).slice(2);
|
||||
const id = randomUUID();
|
||||
chainProgressListeners.set(id, cb);
|
||||
return () => {
|
||||
chainProgressListeners.delete(id);
|
||||
|
||||
@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**", ".worktrees/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
6
global.d.ts
vendored
@@ -587,6 +587,12 @@ declare global {
|
||||
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady?(): void;
|
||||
|
||||
// Quit guard: subscribe to main-process quit requests that query for dirty editors.
|
||||
// Listener is called with no arguments; return value is an unsubscribe function.
|
||||
onCheckDirtyEditors?(listener: () => void): () => void;
|
||||
// Report the dirty-check result back to the main process.
|
||||
reportDirtyEditorsResult?(hasDirty: boolean): void;
|
||||
|
||||
onLanguageChanged?(cb: (language: string) => void): () => void;
|
||||
|
||||
// Chain progress listener for jump host connections
|
||||
|
||||
442
package-lock.json
generated
@@ -74,7 +74,7 @@
|
||||
"@withfig/autocomplete-types": "^1.31.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^40.1.0",
|
||||
"electron": "^40.8.5",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -83,7 +83,7 @@
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"vite": "^7.3.2",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -1475,9 +1475,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1486,9 +1486,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -2356,9 +2356,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2367,9 +2367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -2430,9 +2430,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2451,9 +2451,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -2660,9 +2660,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -3027,29 +3027,6 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
|
||||
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -4431,9 +4408,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
|
||||
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4445,9 +4422,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4459,9 +4436,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4473,9 +4450,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
|
||||
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4487,9 +4464,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4501,9 +4478,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
|
||||
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4515,9 +4492,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
|
||||
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4529,9 +4506,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
|
||||
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4543,9 +4520,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4557,9 +4534,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4571,9 +4548,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -4585,9 +4562,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -4599,9 +4576,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -4613,9 +4590,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -4627,9 +4604,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -4641,9 +4618,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -4655,9 +4632,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -4669,9 +4646,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4683,9 +4660,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4697,9 +4674,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
|
||||
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4711,9 +4688,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4725,9 +4702,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
|
||||
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4739,9 +4716,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
|
||||
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -4753,9 +4730,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4767,9 +4744,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
|
||||
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -6762,9 +6739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7260,6 +7237,29 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/ci-info": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz",
|
||||
@@ -7325,16 +7325,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -7589,9 +7589,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -8628,9 +8628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dir-compare/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8639,9 +8639,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dir-compare/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -8823,9 +8823,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "40.1.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-40.1.0.tgz",
|
||||
"integrity": "sha512-2j/kvw7uF0H1PnzYBzw2k2Q6q16J8ToKrtQzZfsAoXbbMY0l5gQi2DLOauIZLzwp4O01n8Wt/74JhSRwG0yj9A==",
|
||||
"version": "40.8.5",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-40.8.5.tgz",
|
||||
"integrity": "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -9410,9 +9410,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9444,9 +9444,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -9832,9 +9832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -9920,16 +9920,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -10253,9 +10253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10264,9 +10264,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -10663,9 +10663,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"version": "4.12.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -11667,9 +11667,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -12846,12 +12846,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -13685,9 +13685,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -13724,9 +13724,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14484,9 +14484,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
|
||||
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -14500,31 +14500,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.2",
|
||||
"@rollup/rollup-android-arm64": "4.60.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.2",
|
||||
"@rollup/rollup-darwin-x64": "4.60.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.2",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.2",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -15331,9 +15331,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.7",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
|
||||
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -15498,9 +15498,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -16068,9 +16068,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -16161,9 +16161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -16363,9 +16363,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
||||
4
package.json
Executable file → Normal file
@@ -94,7 +94,7 @@
|
||||
"@withfig/autocomplete-types": "^1.31.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^40.1.0",
|
||||
"electron": "^40.8.5",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -103,7 +103,7 @@
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"vite": "^7.3.2",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
BIN
public/icon-win.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
50
public/icon-win.svg
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="100 100 824 824" width="1024" height="1024">
|
||||
<defs>
|
||||
<clipPath id="round">
|
||||
<rect x="100.0" y="100.0" width="824" height="824" rx="185" ry="185" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#round)">
|
||||
<rect x="100.0" y="100.0" width="824" height="824" fill="#002551" />
|
||||
<g transform="translate(161.80 161.80) scale(0.5585)">
|
||||
<g><path style="opacity:1" fill="#f9f9f9" d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#1f2657" d="M 706.5,245.5 C 677.258,242.344 647.925,240.677 618.5,240.5C 649.662,238.284 680.995,239.784 712.5,245C 710.527,245.495 708.527,245.662 706.5,245.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#18214c" d="M 231.5,324.5 C 229.375,356.62 228.042,388.62 227.5,420.5C 226.104,392.965 226.604,365.298 229,337.5C 229.17,331.677 230.003,327.344 231.5,324.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#0c1943" d="M 1026.5,359.5 C 1027.92,371.971 1028.59,384.637 1028.5,397.5C 1028.5,405.008 1028.17,412.341 1027.5,419.5C 1026.5,399.674 1026.17,379.674 1026.5,359.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#505c83" d="M 817.5,544.5 C 815.162,546.04 812.495,546.706 809.5,546.5C 811.905,545.232 814.572,544.565 817.5,544.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#919ab0" d="M 445.5,545.5 C 448.152,545.41 450.485,546.076 452.5,547.5C 449.848,547.59 447.515,546.924 445.5,545.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#022551" d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#032551" d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#0c1a4d" d="M 849.5,574.5 C 822.908,568.314 799.574,574.314 779.5,592.5C 781.087,589.732 782.92,587.065 785,584.5C 785.333,584.833 785.667,585.167 786,585.5C 792.05,581.554 798.217,577.721 804.5,574C 813.233,572.086 822.066,570.919 831,570.5C 837.645,570.207 843.812,571.54 849.5,574.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#98a2bf" d="M 423.5,572.5 C 419.684,573.482 415.684,574.149 411.5,574.5C 415.183,572.75 419.183,572.083 423.5,572.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#9ea6be" d="M 145.5,576.5 C 145.59,579.152 144.924,581.485 143.5,583.5C 143.41,580.848 144.076,578.515 145.5,576.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#132152" d="M 435.5,572.5 C 431.5,572.5 427.5,572.5 423.5,572.5C 419.183,572.083 415.183,572.75 411.5,574.5C 389.242,579.57 372.909,592.403 362.5,613C 356.408,617.241 350.075,617.574 343.5,614C 337.996,608.137 337.163,601.637 341,594.5C 343.929,589.631 347.096,584.965 350.5,580.5C 351.515,581.46 351.515,582.627 350.5,584C 346.551,587.055 344.718,590.888 345,595.5C 344.586,595.043 344.086,594.709 343.5,594.5C 340.638,598.733 339.805,603.4 341,608.5C 347.943,616.139 355.276,616.472 363,609.5C 363.684,608.216 363.517,607.049 362.5,606C 366.772,603.96 369.606,600.126 371,594.5C 374.005,593.423 376.839,591.423 379.5,588.5C 379.291,587.914 378.957,587.414 378.5,587C 379.572,585.716 380.905,585.383 382.5,586C 398.213,573.156 415.88,568.656 435.5,572.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#6c7794" d="M 742.5,596.5 C 741.841,599.455 741.508,602.455 741.5,605.5C 740.848,604.551 740.514,603.385 740.5,602C 740.393,599.779 741.06,597.946 742.5,596.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#6f7b97" d="M 1117.5,604.5 C 1118.77,606.905 1119.43,609.572 1119.5,612.5C 1117.96,610.162 1117.29,607.495 1117.5,604.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#a8aec5" d="M 135.5,622.5 C 135.802,626.79 135.136,630.79 133.5,634.5C 133.717,630.295 134.383,626.295 135.5,622.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#677393" d="M 653.5,662.5 C 634.473,662.218 615.473,662.551 596.5,663.5C 597.263,662.732 598.263,662.232 599.5,662C 617.671,661.171 635.671,661.338 653.5,662.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#032551" d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#01103f" d="M 132.5,640.5 C 131.835,647.48 131.502,654.48 131.5,661.5C 130.669,675.994 130.169,690.661 130,705.5C 128.188,682.722 128.854,660.055 132,637.5C 132.483,638.448 132.649,639.448 132.5,640.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#7c869d" d="M 1119.5,745.5 C 1119.71,748.495 1119.04,751.162 1117.5,753.5C 1117.57,750.572 1118.23,747.905 1119.5,745.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#7581a0" d="M 706.5,791.5 C 705.737,792.268 704.737,792.768 703.5,793C 695.323,793.823 687.323,793.656 679.5,792.5C 688.533,792.716 697.533,792.383 706.5,791.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#a7aec3" d="M 1028.5,883.5 C 1032.79,883.198 1036.79,883.864 1040.5,885.5C 1036.29,885.283 1032.29,884.617 1028.5,883.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#f9f9f9" d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#f8f8f9" d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#adb2c9" d="M 233.5,904.5 C 227.444,906.453 221.111,907.453 214.5,907.5C 220.536,905.419 226.869,904.419 233.5,904.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#bec4d7" d="M 210.5,908.5 C 205.5,910.943 200.166,912.61 194.5,913.5C 199.5,911.057 204.834,909.39 210.5,908.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#9ba0b8" d="M 884.5,984.5 C 884.277,988.162 883.277,991.495 881.5,994.5C 881.723,990.838 882.723,987.505 884.5,984.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#9aa5bc" d="M 1133.5,985.5 C 1134.92,987.515 1135.59,989.848 1135.5,992.5C 1134.08,990.485 1133.41,988.152 1133.5,985.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#adb1c6" d="M 118.5,996.5 C 117.815,1004.82 117.482,1013.15 117.5,1021.5C 116.835,1018.69 116.502,1015.69 116.5,1012.5C 116.429,1006.93 117.096,1001.6 118.5,996.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#c9d0dc" d="M 1135.5,992.5 C 1136.96,998.434 1137.63,1004.6 1137.5,1011C 1137.5,1015.02 1137.17,1018.85 1136.5,1022.5C 1136.59,1012.48 1136.26,1002.48 1135.5,992.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#b5bfcb" d="M 948.5,1025.5 C 948.5,1038.5 948.5,1051.5 948.5,1064.5C 947.167,1064.5 945.833,1064.5 944.5,1064.5C 945.209,1063.6 946.209,1063.26 947.5,1063.5C 947.171,1050.66 947.505,1037.99 948.5,1025.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#8193aa" d="M 232.5,1079.5 C 235.152,1079.41 237.485,1080.08 239.5,1081.5C 236.848,1081.59 234.515,1080.92 232.5,1079.5 Z"/></g>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="104.0" y="104.0"
|
||||
width="816" height="816"
|
||||
rx="181.0" ry="181.0"
|
||||
fill="none" stroke="none" stroke-opacity="0"
|
||||
stroke-width="8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
BIN
public/icon.png
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 56 KiB |
50
public/icon.svg
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="44 44 936 936" width="1024" height="1024">
|
||||
<defs>
|
||||
<clipPath id="round">
|
||||
<rect x="100.0" y="100.0" width="824" height="824" rx="185" ry="185" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#round)">
|
||||
<rect x="100.0" y="100.0" width="824" height="824" fill="#002551" />
|
||||
<g transform="translate(161.80 161.80) scale(0.5585)">
|
||||
<g><path style="opacity:1" fill="#f9f9f9" d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#1f2657" d="M 706.5,245.5 C 677.258,242.344 647.925,240.677 618.5,240.5C 649.662,238.284 680.995,239.784 712.5,245C 710.527,245.495 708.527,245.662 706.5,245.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#18214c" d="M 231.5,324.5 C 229.375,356.62 228.042,388.62 227.5,420.5C 226.104,392.965 226.604,365.298 229,337.5C 229.17,331.677 230.003,327.344 231.5,324.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#0c1943" d="M 1026.5,359.5 C 1027.92,371.971 1028.59,384.637 1028.5,397.5C 1028.5,405.008 1028.17,412.341 1027.5,419.5C 1026.5,399.674 1026.17,379.674 1026.5,359.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#505c83" d="M 817.5,544.5 C 815.162,546.04 812.495,546.706 809.5,546.5C 811.905,545.232 814.572,544.565 817.5,544.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#919ab0" d="M 445.5,545.5 C 448.152,545.41 450.485,546.076 452.5,547.5C 449.848,547.59 447.515,546.924 445.5,545.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#022551" d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#032551" d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#0c1a4d" d="M 849.5,574.5 C 822.908,568.314 799.574,574.314 779.5,592.5C 781.087,589.732 782.92,587.065 785,584.5C 785.333,584.833 785.667,585.167 786,585.5C 792.05,581.554 798.217,577.721 804.5,574C 813.233,572.086 822.066,570.919 831,570.5C 837.645,570.207 843.812,571.54 849.5,574.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#98a2bf" d="M 423.5,572.5 C 419.684,573.482 415.684,574.149 411.5,574.5C 415.183,572.75 419.183,572.083 423.5,572.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#9ea6be" d="M 145.5,576.5 C 145.59,579.152 144.924,581.485 143.5,583.5C 143.41,580.848 144.076,578.515 145.5,576.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#132152" d="M 435.5,572.5 C 431.5,572.5 427.5,572.5 423.5,572.5C 419.183,572.083 415.183,572.75 411.5,574.5C 389.242,579.57 372.909,592.403 362.5,613C 356.408,617.241 350.075,617.574 343.5,614C 337.996,608.137 337.163,601.637 341,594.5C 343.929,589.631 347.096,584.965 350.5,580.5C 351.515,581.46 351.515,582.627 350.5,584C 346.551,587.055 344.718,590.888 345,595.5C 344.586,595.043 344.086,594.709 343.5,594.5C 340.638,598.733 339.805,603.4 341,608.5C 347.943,616.139 355.276,616.472 363,609.5C 363.684,608.216 363.517,607.049 362.5,606C 366.772,603.96 369.606,600.126 371,594.5C 374.005,593.423 376.839,591.423 379.5,588.5C 379.291,587.914 378.957,587.414 378.5,587C 379.572,585.716 380.905,585.383 382.5,586C 398.213,573.156 415.88,568.656 435.5,572.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#6c7794" d="M 742.5,596.5 C 741.841,599.455 741.508,602.455 741.5,605.5C 740.848,604.551 740.514,603.385 740.5,602C 740.393,599.779 741.06,597.946 742.5,596.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#6f7b97" d="M 1117.5,604.5 C 1118.77,606.905 1119.43,609.572 1119.5,612.5C 1117.96,610.162 1117.29,607.495 1117.5,604.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#a8aec5" d="M 135.5,622.5 C 135.802,626.79 135.136,630.79 133.5,634.5C 133.717,630.295 134.383,626.295 135.5,622.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#677393" d="M 653.5,662.5 C 634.473,662.218 615.473,662.551 596.5,663.5C 597.263,662.732 598.263,662.232 599.5,662C 617.671,661.171 635.671,661.338 653.5,662.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#032551" d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#01103f" d="M 132.5,640.5 C 131.835,647.48 131.502,654.48 131.5,661.5C 130.669,675.994 130.169,690.661 130,705.5C 128.188,682.722 128.854,660.055 132,637.5C 132.483,638.448 132.649,639.448 132.5,640.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#7c869d" d="M 1119.5,745.5 C 1119.71,748.495 1119.04,751.162 1117.5,753.5C 1117.57,750.572 1118.23,747.905 1119.5,745.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#7581a0" d="M 706.5,791.5 C 705.737,792.268 704.737,792.768 703.5,793C 695.323,793.823 687.323,793.656 679.5,792.5C 688.533,792.716 697.533,792.383 706.5,791.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#a7aec3" d="M 1028.5,883.5 C 1032.79,883.198 1036.79,883.864 1040.5,885.5C 1036.29,885.283 1032.29,884.617 1028.5,883.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#f9f9f9" d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#f8f8f9" d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#adb2c9" d="M 233.5,904.5 C 227.444,906.453 221.111,907.453 214.5,907.5C 220.536,905.419 226.869,904.419 233.5,904.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#bec4d7" d="M 210.5,908.5 C 205.5,910.943 200.166,912.61 194.5,913.5C 199.5,911.057 204.834,909.39 210.5,908.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#9ba0b8" d="M 884.5,984.5 C 884.277,988.162 883.277,991.495 881.5,994.5C 881.723,990.838 882.723,987.505 884.5,984.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#9aa5bc" d="M 1133.5,985.5 C 1134.92,987.515 1135.59,989.848 1135.5,992.5C 1134.08,990.485 1133.41,988.152 1133.5,985.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#adb1c6" d="M 118.5,996.5 C 117.815,1004.82 117.482,1013.15 117.5,1021.5C 116.835,1018.69 116.502,1015.69 116.5,1012.5C 116.429,1006.93 117.096,1001.6 118.5,996.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#c9d0dc" d="M 1135.5,992.5 C 1136.96,998.434 1137.63,1004.6 1137.5,1011C 1137.5,1015.02 1137.17,1018.85 1136.5,1022.5C 1136.59,1012.48 1136.26,1002.48 1135.5,992.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#b5bfcb" d="M 948.5,1025.5 C 948.5,1038.5 948.5,1051.5 948.5,1064.5C 947.167,1064.5 945.833,1064.5 944.5,1064.5C 945.209,1063.6 946.209,1063.26 947.5,1063.5C 947.171,1050.66 947.505,1037.99 948.5,1025.5 Z"/></g>
|
||||
<g><path style="opacity:1" fill="#8193aa" d="M 232.5,1079.5 C 235.152,1079.41 237.485,1080.08 239.5,1081.5C 236.848,1081.59 234.515,1080.92 232.5,1079.5 Z"/></g>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="104.0" y="104.0"
|
||||
width="816" height="816"
|
||||
rx="181.0" ry="181.0"
|
||||
fill="none" stroke="#ffffff" stroke-opacity="0.4"
|
||||
stroke-width="8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 522 B |
24
public/tray-iconTemplate.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="227 238 570 570" width="22" height="22">
|
||||
<mask id="cutouts">
|
||||
<rect x="0" y="0" width="1024" height="1024" fill="white"/>
|
||||
<g transform="translate(161.80 161.80) scale(0.5585)" fill="black">
|
||||
<!-- Left squinty eye / brow (from logo.svg path #18) -->
|
||||
<path d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/>
|
||||
<!-- Right squinty eye / brow (from logo.svg path #19) -->
|
||||
<path d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/>
|
||||
<!-- Nose + mouth curve (from logo.svg path #28) -->
|
||||
<path d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/>
|
||||
</g>
|
||||
</mask>
|
||||
<g mask="url(#cutouts)" fill="black">
|
||||
<g transform="translate(161.80 161.80) scale(0.5585)">
|
||||
<!-- Head + ears -->
|
||||
<path d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/>
|
||||
<!-- Left paw -->
|
||||
<path d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/>
|
||||
<!-- Right paw -->
|
||||
<path d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 754 B After Width: | Height: | Size: 1023 B |