mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66e881499 | ||
|
|
eea3dd856f | ||
|
|
fd6d0ae18c | ||
|
|
b5ba050d92 | ||
|
|
0159a7de26 |
No files matched your search
@@ -71,13 +71,11 @@ on:
|
||||
- "scripts/linux/**"
|
||||
- "scripts/mosh-extra-resources.cjs"
|
||||
- "scripts/nodePtyConptyPatch.cjs"
|
||||
- "scripts/patch-xterm-macos-column-selection.cjs"
|
||||
- "scripts/patch-xterm-webgl-atlas.cjs"
|
||||
- "scripts/rebuildPatchedNodePty.cjs"
|
||||
- "scripts/resolve-et-bin-release.cjs"
|
||||
- "scripts/resolve-mosh-bin-release.cjs"
|
||||
- "scripts/verify-linux-*.sh"
|
||||
- "scripts/xterm-macos-column-selection.live.test.cjs"
|
||||
- "skills/**"
|
||||
|
||||
# A newer run for the same main push or PR cancels older in-progress work.
|
||||
@@ -245,10 +243,6 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Test macOS Option column selection
|
||||
if: matrix.name == 'macos'
|
||||
run: npm run test:xterm-macos-selection
|
||||
|
||||
- name: Compile ConPTY test helpers
|
||||
if: matrix.name == 'windows'
|
||||
shell: pwsh
|
||||
|
||||
Vendored
+1
-2
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getLogHostVisualSnapshot,
|
||||
handleEscapeKeyDownImpl,
|
||||
handleGlobalHotkeyKeyDownImpl,
|
||||
markForwardedNativeShortcutEvent,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
@@ -36,11 +35,16 @@ class FakeHTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMonacoHTMLElement extends FakeHTMLElement {
|
||||
tagName = 'TEXTAREA';
|
||||
class FakePageHTMLElement extends FakeHTMLElement {
|
||||
tagName = 'DIV';
|
||||
classList = { contains: () => false };
|
||||
|
||||
closest(selector: string): FakeMonacoHTMLElement | null {
|
||||
return selector.includes('monaco') ? this : null;
|
||||
closest(): FakePageHTMLElement | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
hasAttribute(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +96,242 @@ test('global hotkey handler lets terminal font size shortcuts reach xterm', () =
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('terminal close-tab shortcut follows disabled, default, and rebound settings', () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = { querySelectorAll: () => [] } as unknown as Document;
|
||||
|
||||
const dispatch = (key: string, keyBindings = DEFAULT_KEY_BINDINGS) => {
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
const event = {
|
||||
key,
|
||||
code: `Key${key.toUpperCase()}`,
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => { stopped = true; },
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: keyBindings.find((binding) => binding.action === 'closeTab')?.mac ?? null,
|
||||
executeHotkeyAction: (action: string) => { handledActions.push(action); },
|
||||
hotkeyScheme: 'mac',
|
||||
keyBindings,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
return { prevented, stopped };
|
||||
};
|
||||
|
||||
try {
|
||||
const disabledBindings = DEFAULT_KEY_BINDINGS.map((binding) =>
|
||||
binding.action === 'closeTab' ? { ...binding, mac: 'Disabled' } : binding,
|
||||
);
|
||||
assert.deepEqual(dispatch('w', disabledBindings), { prevented: false, stopped: false });
|
||||
assert.deepEqual(handledActions, []);
|
||||
|
||||
assert.deepEqual(dispatch('w'), { prevented: true, stopped: true });
|
||||
assert.deepEqual(handledActions, ['closeTab']);
|
||||
|
||||
handledActions.length = 0;
|
||||
const reboundBindings = DEFAULT_KEY_BINDINGS.map((binding) =>
|
||||
binding.action === 'closeTab' ? { ...binding, mac: '⌘ + E' } : binding,
|
||||
);
|
||||
assert.deepEqual(dispatch('w', reboundBindings), { prevented: false, stopped: false });
|
||||
assert.deepEqual(dispatch('e', reboundBindings), { prevented: true, stopped: true });
|
||||
assert.deepEqual(handledActions, ['closeTab']);
|
||||
} finally {
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('matched close-tab shortcut delegates to the complete close path', () => {
|
||||
const target = new FakePageHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
let completeCloseRequests = 0;
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = { querySelectorAll: () => [] } as unknown as Document;
|
||||
const event = {
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => { stopped = true; },
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
try {
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '⌘ + W',
|
||||
executeCloseTabHotkey: () => { completeCloseRequests += 1; },
|
||||
executeHotkeyAction: (action: string) => { handledActions.push(action); },
|
||||
hotkeyScheme: 'mac',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
} finally {
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(stopped, true);
|
||||
assert.equal(completeCloseRequests, 1);
|
||||
assert.deepEqual(handledActions, []);
|
||||
});
|
||||
|
||||
test('close-tab shortcut follows disabled, default, and rebound settings in focused inputs', () => {
|
||||
const target = new FakeInputHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = { querySelectorAll: () => [] } as unknown as Document;
|
||||
|
||||
const dispatch = (key: string, keyBindings = DEFAULT_KEY_BINDINGS) => {
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
const event = {
|
||||
key,
|
||||
code: `Key${key.toUpperCase()}`,
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => { stopped = true; },
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: keyBindings.find((binding) => binding.action === 'closeTab')?.mac ?? null,
|
||||
executeHotkeyAction: (action: string) => { handledActions.push(action); },
|
||||
hotkeyScheme: 'mac',
|
||||
keyBindings,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
return { prevented, stopped };
|
||||
};
|
||||
|
||||
try {
|
||||
const disabledBindings = DEFAULT_KEY_BINDINGS.map((binding) =>
|
||||
binding.action === 'closeTab' ? { ...binding, mac: 'Disabled' } : binding,
|
||||
);
|
||||
assert.deepEqual(dispatch('w', disabledBindings), { prevented: false, stopped: false });
|
||||
assert.deepEqual(handledActions, []);
|
||||
|
||||
assert.deepEqual(dispatch('w'), { prevented: true, stopped: true });
|
||||
assert.deepEqual(handledActions, ['closeTab']);
|
||||
|
||||
handledActions.length = 0;
|
||||
const reboundBindings = DEFAULT_KEY_BINDINGS.map((binding) =>
|
||||
binding.action === 'closeTab' ? { ...binding, mac: '⌘ + E' } : binding,
|
||||
);
|
||||
assert.deepEqual(dispatch('w', reboundBindings), { prevented: false, stopped: false });
|
||||
assert.deepEqual(dispatch('e', reboundBindings), { prevented: true, stopped: true });
|
||||
assert.deepEqual(handledActions, ['closeTab']);
|
||||
} finally {
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('reassigning Command+W routes it to the newly configured action', () => {
|
||||
const target = new FakePageHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
const keyBindings = DEFAULT_KEY_BINDINGS.map((binding) => {
|
||||
if (binding.action === 'closeTab') return { ...binding, mac: '⌘ + E' };
|
||||
if (binding.action === 'newTab') return { ...binding, mac: '⌘ + W' };
|
||||
return binding;
|
||||
});
|
||||
const event = {
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '⌘ + E',
|
||||
executeHotkeyAction: (action: string) => { handledActions.push(action); },
|
||||
hotkeyScheme: 'mac',
|
||||
keyBindings,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['newTab']);
|
||||
});
|
||||
|
||||
test('reassigning Command+W to a terminal action leaves the real event for xterm', () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
const keyBindings = DEFAULT_KEY_BINDINGS.map((binding) => {
|
||||
if (binding.action === 'closeTab') return { ...binding, mac: '⌘ + E' };
|
||||
if (binding.action === 'searchTerminal') return { ...binding, mac: '⌘ + W' };
|
||||
return binding;
|
||||
});
|
||||
const event = {
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => { stopped = true; },
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: '⌘ + E',
|
||||
executeHotkeyAction: (action: string) => { handledActions.push(action); },
|
||||
hotkeyScheme: 'mac',
|
||||
keyBindings,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
assert.deepEqual(handledActions, []);
|
||||
});
|
||||
|
||||
test('global hotkey handler routes quick switch through focused search inputs', () => {
|
||||
const target = new FakeInputHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
@@ -158,48 +398,6 @@ test('global hotkey handler magnifies panes from focused form inputs', () => {
|
||||
assert.deepEqual(handledActions, ['togglePaneZoom']);
|
||||
});
|
||||
|
||||
test('forwarded native shortcut can run a reassigned global action from Monaco', () => {
|
||||
const target = new FakeMonacoHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
let prevented = false;
|
||||
const event = markForwardedNativeShortcutEvent({
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent);
|
||||
const keyBindings = DEFAULT_KEY_BINDINGS.map((binding) => {
|
||||
if (binding.action === 'closeTab') return { ...binding, mac: 'Disabled' };
|
||||
if (binding.action === 'newTab') return { ...binding, mac: '⌘ + W' };
|
||||
return binding;
|
||||
});
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Disabled',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'mac',
|
||||
keyBindings,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['newTab']);
|
||||
assert.equal(prevented, true);
|
||||
});
|
||||
|
||||
test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
let isQuickSwitcherOpen = false;
|
||||
const setIsQuickSwitcherOpen = (next: boolean) => {
|
||||
|
||||
@@ -14,12 +14,6 @@ import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
const forwardedNativeShortcutEvents = new WeakSet<KeyboardEvent>();
|
||||
|
||||
export function markForwardedNativeShortcutEvent(event: KeyboardEvent): KeyboardEvent {
|
||||
forwardedNativeShortcutEvents.add(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async function deliverKeyboardInteractiveResponse(
|
||||
ctx: Record<string, any>,
|
||||
@@ -232,7 +226,7 @@ export function flushQueuedTrayPanelConnectHostsImpl(getCtx: AppContextGetter) {
|
||||
}
|
||||
|
||||
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
|
||||
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
|
||||
const { HOTKEY_DEBUG, closeTabKeyStr, executeCloseTabHotkey, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
|
||||
{
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -274,9 +268,9 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
(isFormElement || isMonacoElement)
|
||||
&& !isXtermInput
|
||||
&& e.key !== 'Escape'
|
||||
&& !isCloseTabHotkey
|
||||
&& !isQuickSwitchHotkey
|
||||
&& !isPaneZoomHotkey
|
||||
&& !forwardedNativeShortcutEvents.has(e)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -325,6 +319,10 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
isTerminalInPath,
|
||||
});
|
||||
}
|
||||
if (binding.action === 'closeTab' && executeCloseTabHotkey) {
|
||||
executeCloseTabHotkey(e);
|
||||
return;
|
||||
}
|
||||
executeHotkeyAction(binding.action, e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,10 @@ import { collectSessionIds } from '../../domain/workspace';
|
||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
|
||||
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
|
||||
import {
|
||||
resolveWindowCommandCloseRequestIntent,
|
||||
type WindowCommandCloseRequest,
|
||||
} from '../state/windowCommandClose';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
|
||||
import {
|
||||
@@ -90,7 +93,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
|
||||
|
||||
type OpenSessionInNewWindowPayload = {
|
||||
title?: string;
|
||||
@@ -596,7 +599,28 @@ export function AppSideEffects() {
|
||||
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => { return handleTrayTogglePortForwardImpl(() => ({ hasRuntimeTunnel, hosts, identities, keys, knownHosts: effectiveKnownHosts, portForwardingRules, resolveEffectiveHost, ruleId, start, startTunnel, stopTunnel, t, terminalSettings, toast, undefined }), ruleId, start); });
|
||||
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
|
||||
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG,
|
||||
closeTabKeyStr,
|
||||
e,
|
||||
executeCloseTabHotkey: (event: KeyboardEvent) => {
|
||||
void handleWindowCommandCloseRequest({
|
||||
source: 'keyboard',
|
||||
input: {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
altKey: event.altKey,
|
||||
shiftKey: event.shiftKey,
|
||||
},
|
||||
});
|
||||
},
|
||||
executeHotkeyAction,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
matchesKeyBinding,
|
||||
}), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen, sftpPaneMagnificationRef, terminalPaneMagnificationRef }), e); });
|
||||
|
||||
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
|
||||
@@ -1092,38 +1116,25 @@ export function AppSideEffects() {
|
||||
confirmIfBusyLocalTerminal,
|
||||
]);
|
||||
|
||||
const handleWindowCommandCloseRequest = useCallback(async () => {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||
|
||||
const intent = resolveWindowCommandCloseIntent({
|
||||
const handleWindowCommandCloseRequest = useCallback(async (request?: WindowCommandCloseRequest) => {
|
||||
const intent = resolveWindowCommandCloseRequestIntent({
|
||||
request,
|
||||
closeTabKeyStr,
|
||||
isMac: hotkeyScheme === 'mac',
|
||||
activeTabId: activeTabStore.getActiveTabId(),
|
||||
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
sessionIds: sessions.map((session) => session.id),
|
||||
workspaceIds: workspaces.map((workspace) => workspace.id),
|
||||
logViewIds: logViews.map((logView) => logView.id),
|
||||
pluginViewTabIds: pluginViewTabs.map((tab) => tab.id),
|
||||
hasOpenDialog: Boolean(topmostDialogClose),
|
||||
closeTabShortcutEnabled: isPrimaryModifierWBinding(closeTabKeyStr, matchesKeyBinding, true),
|
||||
});
|
||||
if (!intent) return;
|
||||
|
||||
if (intent.kind === 'forwardShortcut') {
|
||||
// The native macOS menu accelerator consumed the original key event.
|
||||
// Re-dispatch it so a freed Cmd+W can still be assigned to another action.
|
||||
const forwardedEvent = markForwardedNativeShortcutEvent(new KeyboardEvent('keydown', {
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
(document.activeElement ?? window).dispatchEvent(forwardedEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent.kind === 'closeDialog') {
|
||||
topmostDialogClose?.click();
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||
if (topmostDialogClose) {
|
||||
topmostDialogClose.click();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1138,14 +1149,14 @@ export function AppSideEffects() {
|
||||
}
|
||||
|
||||
await netcattyBridge.get()?.windowClose?.();
|
||||
}, [closeLogView, closeTabKeyStr, editorTabs, executeHotkeyAction, logViews, pluginViewTabs, sessions, workspaces]);
|
||||
}, [closeLogView, closeTabKeyStr, editorTabs, executeHotkeyAction, hotkeyScheme, logViews, pluginViewTabs, sessions, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cmd/Ctrl+W from the app menu arrives via IPC, not the keydown listener.
|
||||
// Gate it while locked so sessions/tabs cannot close behind the overlay.
|
||||
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
|
||||
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.((request) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
|
||||
void handleWindowCommandCloseRequest();
|
||||
void handleWindowCommandCloseRequest(request);
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [appLockLocked, handleWindowCommandCloseRequest]);
|
||||
|
||||
@@ -358,12 +358,6 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Auto-import system known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': 'When Vault loads, silently import entries from the OpenSSH known_hosts file. Turn this off to keep Netcatty known hosts separate; Scan System still imports on demand.',
|
||||
'settings.vault.notesFont': 'Code font',
|
||||
'settings.vault.notesFontDesc': 'Font family used for code blocks and inline code in notes',
|
||||
'settings.vault.notesFontSize': 'Note font size',
|
||||
'settings.vault.notesFontSizeDesc': 'Font size used for note body text',
|
||||
'settings.vault.notesCodeFontSize': 'Code font size',
|
||||
'settings.vault.notesCodeFontSizeDesc': 'Font size used for code blocks and inline code in notes',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
|
||||
@@ -34,61 +34,6 @@ export const enVaultMessages: Messages = {
|
||||
'notes.empty.desc': 'Create Markdown notes for runbooks, reminders, and server handoff details.',
|
||||
'notes.empty.group': 'No notes in this notebook.',
|
||||
'notes.search.noResults': 'No notes found.',
|
||||
'notes.toolbar.modeLive': 'Live Preview',
|
||||
'notes.toolbar.modeLiveTitle': 'Live Preview Mode (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': 'Source Mode',
|
||||
'notes.toolbar.modeSourceTitle': 'Source Mode (Markdown code)',
|
||||
'notes.toolbar.modePreview': 'Reading Mode',
|
||||
'notes.toolbar.modePreviewTitle': 'Reading Mode (Read-only)',
|
||||
'notes.toolbar.undo': 'Undo (Ctrl+Z)',
|
||||
'notes.toolbar.redo': 'Redo (Ctrl+Y)',
|
||||
'notes.toolbar.typography': 'Code font and typography settings',
|
||||
'notes.toolbar.customCodeFont': 'Custom Code Font',
|
||||
'notes.toolbar.fontsAvailable': '{count} fonts available',
|
||||
'notes.toolbar.searchFont': 'Search fonts...',
|
||||
'notes.toolbar.noFontsFound': 'No matching monospace fonts found',
|
||||
'notes.toolbar.defaultFont': 'Default Monospace (Default)',
|
||||
'notes.toolbar.bodyFontSize': 'Body Font Size',
|
||||
'notes.toolbar.codeFontSize': 'Code Font Size',
|
||||
'notes.toolbar.headingLevel': 'Heading Level',
|
||||
'notes.toolbar.h1': 'Heading 1',
|
||||
'notes.toolbar.h2': 'Heading 2',
|
||||
'notes.toolbar.h3': 'Heading 3',
|
||||
'notes.toolbar.h4': 'Heading 4',
|
||||
'notes.toolbar.bold': 'Bold (Ctrl+B)',
|
||||
'notes.toolbar.italic': 'Italic (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': 'Strikethrough',
|
||||
'notes.toolbar.underline': 'Underline',
|
||||
'notes.toolbar.inlineCode': 'Inline Code',
|
||||
'notes.toolbar.bulletList': 'Bullet List',
|
||||
'notes.toolbar.orderedList': 'Numbered List',
|
||||
'notes.toolbar.taskList': 'Task List',
|
||||
'notes.toolbar.quote': 'Quote',
|
||||
'notes.toolbar.codeBlock': 'Code Block',
|
||||
'notes.toolbar.mathFormula': 'Math Formula (LaTeX / Math)',
|
||||
'notes.toolbar.table': 'Insert Table',
|
||||
'notes.toolbar.divider': 'Divider',
|
||||
'notes.toolbar.link': 'Insert Link',
|
||||
'notes.toolbar.image': 'Insert Image',
|
||||
'notes.action.pin': 'Pin Note',
|
||||
'notes.action.unpin': 'Unpin Note',
|
||||
'notes.tag.remove': 'Remove tag',
|
||||
'notes.tag.placeholder': 'Tag name...',
|
||||
'notes.tag.add': 'Add tag',
|
||||
'notes.outline.title': 'Table of Contents',
|
||||
'notes.outline.titleWithCount': 'Table of Contents ({count})',
|
||||
'notes.outline.empty': 'No outline headings',
|
||||
'notes.outline.emptyHint': 'Use # and ## in Markdown to create headings',
|
||||
'notes.export.share': 'Export & Share',
|
||||
'notes.export.currentNote': 'Current Note',
|
||||
'notes.export.exportMarkdown': 'Export as Markdown (.md)',
|
||||
'notes.export.exportHtml': 'Export as HTML (.html)',
|
||||
'notes.export.copyMarkdown': 'Copy Markdown Source',
|
||||
'notes.export.allNotes': 'All Notes',
|
||||
'notes.export.exportAllZip': 'Export All as ZIP Archive',
|
||||
'notes.export.toast.markdownSuccess': 'Markdown file exported',
|
||||
'notes.export.toast.htmlSuccess': 'HTML file exported',
|
||||
'notes.export.toast.zipSuccess': 'All notes exported as ZIP backup',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
@@ -452,12 +397,12 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.conflict.title': 'File Conflict',
|
||||
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
|
||||
'sftp.conflict.folderTitle': 'Folder Conflict',
|
||||
'sftp.conflict.folderDesc': 'Choose how to continue.',
|
||||
'sftp.conflict.folderDesc': 'A folder with the same name already exists at the destination',
|
||||
'sftp.conflict.folderFileDesc': 'A file with the same name already exists at the destination. A folder cannot be merged with a file.',
|
||||
'sftp.conflict.folderSymlinkDesc': 'A symbolic link with the same name already exists at the destination. A folder cannot be merged with a symbolic link.',
|
||||
'sftp.conflict.folderUnknownDesc': 'An item with the same name exists at the destination, but its type could not be confirmed. Replace is unavailable to protect destination data.',
|
||||
'sftp.conflict.folderMergeHint': 'Merge: keeps destination-only content.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Replace: deletes destination-only content and cannot be undone.',
|
||||
'sftp.conflict.folderMergeHint': 'Merge keeps files and sub-folders that exist only in the destination.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Replace deletes all destination files and sub-folders that are not in the source. Deleted data cannot be recovered.',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'already exists',
|
||||
'sftp.conflict.existingFile': 'Existing file',
|
||||
'sftp.conflict.newFile': 'New file',
|
||||
|
||||
@@ -279,12 +279,6 @@ export const esCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Muestra la barra lateral de lista de hosts y su alternador en la barra superior en las pestañas de terminal y editor.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Importar automáticamente known_hosts del sistema',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': 'Cuando se carga el Vault, importa en silencio las entradas del archivo known_hosts de OpenSSH. Desactívalo para mantener los hosts conocidos de Netcatty separados; Escanear sistema sigue importando a pedido.',
|
||||
'settings.vault.notesFont': 'Fuente de código',
|
||||
'settings.vault.notesFontDesc': 'Familia tipográfica para bloques de código y código en línea en notas',
|
||||
'settings.vault.notesFontSize': 'Tamaño de fuente de notas',
|
||||
'settings.vault.notesFontSizeDesc': 'Tamaño de fuente para el cuerpo de texto de las notas',
|
||||
'settings.vault.notesCodeFontSize': 'Tamaño de fuente de código',
|
||||
'settings.vault.notesCodeFontSizeDesc': 'Tamaño de fuente para bloques de código y código en línea en notas',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Actualización disponible',
|
||||
|
||||
@@ -34,61 +34,6 @@ export const esVaultMessages: Messages = {
|
||||
'notes.empty.desc': 'Crea notas Markdown para runbooks, recordatorios y detalles de traspaso de servidores.',
|
||||
'notes.empty.group': 'No hay notas en este cuaderno.',
|
||||
'notes.search.noResults': 'No se encontraron notas.',
|
||||
'notes.toolbar.modeLive': 'Vista en vivo',
|
||||
'notes.toolbar.modeLiveTitle': 'Modo vista en vivo (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': 'Modo fuente',
|
||||
'notes.toolbar.modeSourceTitle': 'Modo fuente (código Markdown)',
|
||||
'notes.toolbar.modePreview': 'Modo lectura',
|
||||
'notes.toolbar.modePreviewTitle': 'Modo lectura (solo lectura)',
|
||||
'notes.toolbar.undo': 'Deshacer (Ctrl+Z)',
|
||||
'notes.toolbar.redo': 'Rehacer (Ctrl+Y)',
|
||||
'notes.toolbar.typography': 'Fuente de código y ajustes de tipografía',
|
||||
'notes.toolbar.customCodeFont': 'Fuente de código personalizada',
|
||||
'notes.toolbar.fontsAvailable': '{count} fuentes disponibles',
|
||||
'notes.toolbar.searchFont': 'Buscar fuentes...',
|
||||
'notes.toolbar.noFontsFound': 'No se encontraron fuentes monoespaciadas coincidentes',
|
||||
'notes.toolbar.defaultFont': 'Monoespaciado predeterminado (Default)',
|
||||
'notes.toolbar.bodyFontSize': 'Tamaño de texto',
|
||||
'notes.toolbar.codeFontSize': 'Tamaño de código',
|
||||
'notes.toolbar.headingLevel': 'Nivel de encabezado',
|
||||
'notes.toolbar.h1': 'Encabezado 1',
|
||||
'notes.toolbar.h2': 'Encabezado 2',
|
||||
'notes.toolbar.h3': 'Encabezado 3',
|
||||
'notes.toolbar.h4': 'Encabezado 4',
|
||||
'notes.toolbar.bold': 'Negrita (Ctrl+B)',
|
||||
'notes.toolbar.italic': 'Cursiva (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': 'Tachado',
|
||||
'notes.toolbar.underline': 'Subrayado',
|
||||
'notes.toolbar.inlineCode': 'Código en línea',
|
||||
'notes.toolbar.bulletList': 'Lista con viñetas',
|
||||
'notes.toolbar.orderedList': 'Lista numerada',
|
||||
'notes.toolbar.taskList': 'Lista de tareas',
|
||||
'notes.toolbar.quote': 'Cita',
|
||||
'notes.toolbar.codeBlock': 'Bloque de código',
|
||||
'notes.toolbar.mathFormula': 'Fórmula matemática (LaTeX / Math)',
|
||||
'notes.toolbar.table': 'Insertar tabla',
|
||||
'notes.toolbar.divider': 'Línea divisoria',
|
||||
'notes.toolbar.link': 'Insertar enlace',
|
||||
'notes.toolbar.image': 'Insertar imagen',
|
||||
'notes.action.pin': 'Fijar nota',
|
||||
'notes.action.unpin': 'Desfijar nota',
|
||||
'notes.tag.remove': 'Eliminar etiqueta',
|
||||
'notes.tag.placeholder': 'Nombre de etiqueta...',
|
||||
'notes.tag.add': 'Añadir etiqueta',
|
||||
'notes.outline.title': 'Índice de contenido',
|
||||
'notes.outline.titleWithCount': 'Índice de contenido ({count})',
|
||||
'notes.outline.empty': 'Sin encabezados de esquema',
|
||||
'notes.outline.emptyHint': 'Use # y ## en Markdown para crear encabezados',
|
||||
'notes.export.share': 'Exportar y compartir',
|
||||
'notes.export.currentNote': 'Nota actual',
|
||||
'notes.export.exportMarkdown': 'Exportar como Markdown (.md)',
|
||||
'notes.export.exportHtml': 'Exportar como HTML (.html)',
|
||||
'notes.export.copyMarkdown': 'Copiar código Markdown',
|
||||
'notes.export.allNotes': 'Todas las notas',
|
||||
'notes.export.exportAllZip': 'Exportar todo como archivo ZIP',
|
||||
'notes.export.toast.markdownSuccess': 'Archivo Markdown exportado',
|
||||
'notes.export.toast.htmlSuccess': 'Archivo HTML exportado',
|
||||
'notes.export.toast.zipSuccess': 'Todas las notas exportadas como copia de seguridad ZIP',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Agrega datos a tu Vault',
|
||||
@@ -452,12 +397,12 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.conflict.title': 'Conflicto de archivos',
|
||||
'sftp.conflict.desc': 'Ya existe un archivo con el mismo nombre en el destino',
|
||||
'sftp.conflict.folderTitle': 'Conflicto de carpetas',
|
||||
'sftp.conflict.folderDesc': 'Elige cómo continuar.',
|
||||
'sftp.conflict.folderDesc': 'Ya existe una carpeta con el mismo nombre en el destino',
|
||||
'sftp.conflict.folderFileDesc': 'Ya existe un archivo con el mismo nombre en el destino. Una carpeta no se puede combinar con un archivo.',
|
||||
'sftp.conflict.folderSymlinkDesc': 'Ya existe un enlace simbólico con el mismo nombre en el destino. Una carpeta no se puede combinar con un enlace simbólico.',
|
||||
'sftp.conflict.folderUnknownDesc': 'Ya existe un elemento con el mismo nombre en el destino, pero no se pudo confirmar su tipo. Reemplazar no está disponible para proteger los datos del destino.',
|
||||
'sftp.conflict.folderMergeHint': 'Combinar: conserva el contenido que solo existe en el destino.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Reemplazar: elimina el contenido exclusivo del destino y no se puede deshacer.',
|
||||
'sftp.conflict.folderMergeHint': 'Combinar conserva los archivos y subcarpetas que solo existen en el destino.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Reemplazar elimina todos los archivos y subcarpetas del destino que no estén en el origen. Los datos eliminados no se pueden recuperar.',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'ya existe',
|
||||
'sftp.conflict.existingFile': 'Archivo existente',
|
||||
'sftp.conflict.newFile': 'Archivo nuevo',
|
||||
|
||||
@@ -342,12 +342,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Автоимпорт системного known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': 'При открытии хранилища тихо импортировать записи из OpenSSH known_hosts. Отключите, чтобы держать известные хосты Netcatty отдельно; кнопка «Сканировать систему» по-прежнему импортирует вручную.',
|
||||
'settings.vault.notesFont': 'Шрифт кода',
|
||||
'settings.vault.notesFontDesc': 'Шрифт для блоков кода и встроенного кода в заметках',
|
||||
'settings.vault.notesFontSize': 'Размер шрифта заметок',
|
||||
'settings.vault.notesFontSizeDesc': 'Размер шрифта для основного текста заметок',
|
||||
'settings.vault.notesCodeFontSize': 'Размер шрифта кода',
|
||||
'settings.vault.notesCodeFontSizeDesc': 'Размер шрифта для блоков кода и встроенного кода в заметках',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Доступно обновление',
|
||||
|
||||
@@ -34,61 +34,6 @@ export const ruVaultMessages: Messages = {
|
||||
'notes.empty.desc': 'Создавайте Markdown-заметки для инструкций, напоминаний и передачи серверов.',
|
||||
'notes.empty.group': 'В этом блокноте пока нет заметок.',
|
||||
'notes.search.noResults': 'Заметки не найдены.',
|
||||
'notes.toolbar.modeLive': 'Живой просмотр',
|
||||
'notes.toolbar.modeLiveTitle': 'Режим живого просмотра (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': 'Исходный код',
|
||||
'notes.toolbar.modeSourceTitle': 'Режим исходного кода (Markdown)',
|
||||
'notes.toolbar.modePreview': 'Чтение',
|
||||
'notes.toolbar.modePreviewTitle': 'Режим чтения (только для чтения)',
|
||||
'notes.toolbar.undo': 'Отменить (Ctrl+Z)',
|
||||
'notes.toolbar.redo': 'Повторить (Ctrl+Y)',
|
||||
'notes.toolbar.typography': 'Шрифт кода и параметры оформления',
|
||||
'notes.toolbar.customCodeFont': 'Пользовательский шрифт кода',
|
||||
'notes.toolbar.fontsAvailable': '{count} доступных шрифтов',
|
||||
'notes.toolbar.searchFont': 'Поиск шрифтов...',
|
||||
'notes.toolbar.noFontsFound': 'Совпадающие моноширинные шрифты не найдены',
|
||||
'notes.toolbar.defaultFont': 'Моноширинный по умолчанию (Default)',
|
||||
'notes.toolbar.bodyFontSize': 'Размер текста',
|
||||
'notes.toolbar.codeFontSize': 'Размер кода',
|
||||
'notes.toolbar.headingLevel': 'Уровень заголовка',
|
||||
'notes.toolbar.h1': 'Заголовок 1',
|
||||
'notes.toolbar.h2': 'Заголовок 2',
|
||||
'notes.toolbar.h3': 'Заголовок 3',
|
||||
'notes.toolbar.h4': 'Заголовок 4',
|
||||
'notes.toolbar.bold': 'Жирный (Ctrl+B)',
|
||||
'notes.toolbar.italic': 'Курсив (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': 'Зачёркнутый',
|
||||
'notes.toolbar.underline': 'Подчёркнутый',
|
||||
'notes.toolbar.inlineCode': 'Встроенный код',
|
||||
'notes.toolbar.bulletList': 'Маркированный список',
|
||||
'notes.toolbar.orderedList': 'Нумерованный список',
|
||||
'notes.toolbar.taskList': 'Список задач',
|
||||
'notes.toolbar.quote': 'Цитата',
|
||||
'notes.toolbar.codeBlock': 'Блок кода',
|
||||
'notes.toolbar.mathFormula': 'Математическая формула (LaTeX / Math)',
|
||||
'notes.toolbar.table': 'Вставить таблицу',
|
||||
'notes.toolbar.divider': 'Разделитель',
|
||||
'notes.toolbar.link': 'Вставить ссылку',
|
||||
'notes.toolbar.image': 'Вставить изображение',
|
||||
'notes.action.pin': 'Закрепить заметку',
|
||||
'notes.action.unpin': 'Открепить заметку',
|
||||
'notes.tag.remove': 'Удалить тег',
|
||||
'notes.tag.placeholder': 'Имя тега...',
|
||||
'notes.tag.add': 'Добавить тег',
|
||||
'notes.outline.title': 'Оглавление',
|
||||
'notes.outline.titleWithCount': 'Оглавление ({count})',
|
||||
'notes.outline.empty': 'Нет заголовков',
|
||||
'notes.outline.emptyHint': 'Используйте # и ## в Markdown для создания заголовков',
|
||||
'notes.export.share': 'Экспорт и общий доступ',
|
||||
'notes.export.currentNote': 'Текущая заметка',
|
||||
'notes.export.exportMarkdown': 'Экспорт в Markdown (.md)',
|
||||
'notes.export.exportHtml': 'Экспорт в HTML (.html)',
|
||||
'notes.export.copyMarkdown': 'Копировать исходный Markdown',
|
||||
'notes.export.allNotes': 'Все заметки',
|
||||
'notes.export.exportAllZip': 'Экспортировать все в ZIP-архив',
|
||||
'notes.export.toast.markdownSuccess': 'Markdown-файл экспортирован',
|
||||
'notes.export.toast.htmlSuccess': 'HTML-файл экспортирован',
|
||||
'notes.export.toast.zipSuccess': 'Все заметки экспортированы в архив ZIP',
|
||||
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
@@ -488,12 +433,12 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.conflict.title': 'Конфликт файлов',
|
||||
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
|
||||
'sftp.conflict.folderTitle': 'Конфликт папок',
|
||||
'sftp.conflict.folderDesc': 'Выберите действие.',
|
||||
'sftp.conflict.folderDesc': 'В месте назначения уже существует папка с таким именем',
|
||||
'sftp.conflict.folderFileDesc': 'В месте назначения уже существует файл с таким именем. Папку нельзя объединить с файлом.',
|
||||
'sftp.conflict.folderSymlinkDesc': 'В месте назначения уже существует символическая ссылка с таким именем. Папку нельзя объединить с символической ссылкой.',
|
||||
'sftp.conflict.folderUnknownDesc': 'В месте назначения уже существует элемент с таким именем, но его тип не удалось определить. Замена недоступна для защиты данных в месте назначения.',
|
||||
'sftp.conflict.folderMergeHint': 'Объединить: сохранить содержимое, которое есть только в папке назначения.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Заменить: удалить содержимое, которое есть только в папке назначения. Это нельзя отменить.',
|
||||
'sftp.conflict.folderMergeHint': 'Объединение сохраняет файлы и подпапки, которые существуют только в папке назначения.',
|
||||
'sftp.conflict.folderReplaceWarning': 'Замена удалит все файлы и подпапки в папке назначения, которых нет в исходной папке. Удалённые данные нельзя восстановить.',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
|
||||
'sftp.conflict.existingFile': 'Существующий файл',
|
||||
'sftp.conflict.newFile': 'Новый файл',
|
||||
|
||||
@@ -355,12 +355,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
|
||||
'settings.vault.autoImportSystemKnownHosts': '自动导入系统 known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': '打开主机库时静默导入 OpenSSH known_hosts 中的记录。关闭后,Netcatty 已知主机与系统信任库保持独立;仍可通过「扫描系统」手动导入。',
|
||||
'settings.vault.notesFont': '代码字体',
|
||||
'settings.vault.notesFontDesc': '自定义笔记中代码块与行内代码使用的字体',
|
||||
'settings.vault.notesFontSize': '笔记字号',
|
||||
'settings.vault.notesFontSizeDesc': '自定义笔记正文字体大小',
|
||||
'settings.vault.notesCodeFontSize': '代码字号',
|
||||
'settings.vault.notesCodeFontSizeDesc': '自定义笔记中代码块与行内代码的字体大小',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
|
||||
@@ -34,61 +34,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'notes.empty.desc': '可以记录运维手册、临时备忘、服务器交接信息。',
|
||||
'notes.empty.group': '这个笔记本里还没有笔记。',
|
||||
'notes.search.noResults': '没有找到笔记。',
|
||||
'notes.toolbar.modeLive': '实时预览',
|
||||
'notes.toolbar.modeLiveTitle': '实时预览模式 (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': '源码模式',
|
||||
'notes.toolbar.modeSourceTitle': '源码模式 (Markdown 代码)',
|
||||
'notes.toolbar.modePreview': '阅读模式',
|
||||
'notes.toolbar.modePreviewTitle': '阅读模式 (只读排版)',
|
||||
'notes.toolbar.undo': '撤销 (Ctrl+Z)',
|
||||
'notes.toolbar.redo': '重做 (Ctrl+Y)',
|
||||
'notes.toolbar.typography': '代码字体与排版设置',
|
||||
'notes.toolbar.customCodeFont': '自定义代码字体',
|
||||
'notes.toolbar.fontsAvailable': '共 {count} 款可用字体',
|
||||
'notes.toolbar.searchFont': '搜索字体...',
|
||||
'notes.toolbar.noFontsFound': '未找到匹配的等宽字体',
|
||||
'notes.toolbar.defaultFont': '默认等宽字体 (Default)',
|
||||
'notes.toolbar.bodyFontSize': '正文字号',
|
||||
'notes.toolbar.codeFontSize': '代码字号',
|
||||
'notes.toolbar.headingLevel': '标题层级',
|
||||
'notes.toolbar.h1': '一级标题',
|
||||
'notes.toolbar.h2': '二级标题',
|
||||
'notes.toolbar.h3': '三级标题',
|
||||
'notes.toolbar.h4': '四级标题',
|
||||
'notes.toolbar.bold': '加粗 (Ctrl+B)',
|
||||
'notes.toolbar.italic': '斜体 (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': '删除线',
|
||||
'notes.toolbar.underline': '下划线',
|
||||
'notes.toolbar.inlineCode': '行内代码',
|
||||
'notes.toolbar.bulletList': '无序列表',
|
||||
'notes.toolbar.orderedList': '有序列表',
|
||||
'notes.toolbar.taskList': '待办任务清单',
|
||||
'notes.toolbar.quote': '引用块',
|
||||
'notes.toolbar.codeBlock': '代码块',
|
||||
'notes.toolbar.mathFormula': '数学公式块 (LaTeX / Math)',
|
||||
'notes.toolbar.table': '插入表格',
|
||||
'notes.toolbar.divider': '分割线',
|
||||
'notes.toolbar.link': '插入超链接',
|
||||
'notes.toolbar.image': '插入图片',
|
||||
'notes.action.pin': '置顶笔记',
|
||||
'notes.action.unpin': '取消置顶',
|
||||
'notes.tag.remove': '移除标签',
|
||||
'notes.tag.placeholder': '标签名...',
|
||||
'notes.tag.add': '添加标签',
|
||||
'notes.outline.title': '大纲目录',
|
||||
'notes.outline.titleWithCount': '大纲目录 ({count})',
|
||||
'notes.outline.empty': '暂无标题大纲',
|
||||
'notes.outline.emptyHint': '在 Markdown 中使用 #、## 编写各级标题',
|
||||
'notes.export.share': '导出与分享',
|
||||
'notes.export.currentNote': '当前笔记',
|
||||
'notes.export.exportMarkdown': '导出为 Markdown (.md)',
|
||||
'notes.export.exportHtml': '导出为 HTML (.html)',
|
||||
'notes.export.copyMarkdown': '复制 Markdown 源码',
|
||||
'notes.export.allNotes': '全部笔记',
|
||||
'notes.export.exportAllZip': '导出全部为 ZIP 压缩包',
|
||||
'notes.export.toast.markdownSuccess': '已导出 Markdown 文件',
|
||||
'notes.export.toast.htmlSuccess': '已导出 HTML 文件',
|
||||
'notes.export.toast.zipSuccess': '已导出全部笔记备份 ZIP',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '选择主机',
|
||||
@@ -967,12 +912,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
'sftp.conflict.title': '文件冲突',
|
||||
'sftp.conflict.desc': '目标位置已存在同名文件',
|
||||
'sftp.conflict.folderTitle': '文件夹冲突',
|
||||
'sftp.conflict.folderDesc': '请选择处理方式。',
|
||||
'sftp.conflict.folderDesc': '目标位置已存在同名文件夹',
|
||||
'sftp.conflict.folderFileDesc': '目标位置已存在同名文件,文件夹无法与文件合并。',
|
||||
'sftp.conflict.folderSymlinkDesc': '目标位置已存在同名符号链接,文件夹无法与符号链接合并。',
|
||||
'sftp.conflict.folderUnknownDesc': '目标位置已存在同名项目,但无法确认其类型。为保护目标数据,替换操作不可用。',
|
||||
'sftp.conflict.folderMergeHint': '合并:保留目标中独有的内容。',
|
||||
'sftp.conflict.folderReplaceWarning': '替换:删除目标中独有的内容,且无法恢复。',
|
||||
'sftp.conflict.folderMergeHint': '合并会保留仅存在于目标文件夹中的文件和子文件夹。',
|
||||
'sftp.conflict.folderReplaceWarning': '替换会删除目标文件夹中所有未出现在源文件夹里的文件和子文件夹,删除的数据无法恢复。',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
'sftp.conflict.existingFile': '已有文件',
|
||||
'sftp.conflict.newFile': '新文件',
|
||||
|
||||
@@ -358,12 +358,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器分頁顯示主機列表側欄及頂部開關。',
|
||||
'settings.vault.autoImportSystemKnownHosts': '自動匯入系統 known_hosts',
|
||||
'settings.vault.autoImportSystemKnownHostsDesc': '開啟主機庫時靜默匯入 OpenSSH known_hosts 中的記錄。關閉後,Netcatty 已知主機與系統信任庫保持獨立;仍可透過「掃描系統」手動匯入。',
|
||||
'settings.vault.notesFont': '程式碼字型',
|
||||
'settings.vault.notesFontDesc': '自訂筆記中程式碼區塊與行內程式碼使用的字型',
|
||||
'settings.vault.notesFontSize': '筆記字級',
|
||||
'settings.vault.notesFontSizeDesc': '自訂筆記內文字型大小',
|
||||
'settings.vault.notesCodeFontSize': '程式碼字級',
|
||||
'settings.vault.notesCodeFontSizeDesc': '自訂筆記中程式碼區塊與行內程式碼的文字大小',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '發現新版本',
|
||||
|
||||
@@ -34,61 +34,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'notes.empty.desc': '可以記錄運維手冊、臨時備忘、伺服器交接資訊。',
|
||||
'notes.empty.group': '這個筆記本裡還沒有筆記。',
|
||||
'notes.search.noResults': '沒有找到筆記。',
|
||||
'notes.toolbar.modeLive': '即時預覽',
|
||||
'notes.toolbar.modeLiveTitle': '即時預覽模式 (WYSIWYG)',
|
||||
'notes.toolbar.modeSource': '原始碼模式',
|
||||
'notes.toolbar.modeSourceTitle': '原始碼模式 (Markdown 程式碼)',
|
||||
'notes.toolbar.modePreview': '閱讀模式',
|
||||
'notes.toolbar.modePreviewTitle': '閱讀模式 (唯讀排版)',
|
||||
'notes.toolbar.undo': '復原 (Ctrl+Z)',
|
||||
'notes.toolbar.redo': '重做 (Ctrl+Y)',
|
||||
'notes.toolbar.typography': '程式碼字型與排版設定',
|
||||
'notes.toolbar.customCodeFont': '自訂程式碼字型',
|
||||
'notes.toolbar.fontsAvailable': '共 {count} 款可用字型',
|
||||
'notes.toolbar.searchFont': '搜尋字型...',
|
||||
'notes.toolbar.noFontsFound': '未找到相符的等寬字型',
|
||||
'notes.toolbar.defaultFont': '預設等寬字型 (Default)',
|
||||
'notes.toolbar.bodyFontSize': '內文字級',
|
||||
'notes.toolbar.codeFontSize': '程式碼字級',
|
||||
'notes.toolbar.headingLevel': '標題層級',
|
||||
'notes.toolbar.h1': '一級標題',
|
||||
'notes.toolbar.h2': '二級標題',
|
||||
'notes.toolbar.h3': '三級標題',
|
||||
'notes.toolbar.h4': '四級標題',
|
||||
'notes.toolbar.bold': '粗體 (Ctrl+B)',
|
||||
'notes.toolbar.italic': '斜體 (Ctrl+I)',
|
||||
'notes.toolbar.strikethrough': '刪除線',
|
||||
'notes.toolbar.underline': '底線',
|
||||
'notes.toolbar.inlineCode': '行內程式碼',
|
||||
'notes.toolbar.bulletList': '無序列表',
|
||||
'notes.toolbar.orderedList': '有序列表',
|
||||
'notes.toolbar.taskList': '待辦任務清單',
|
||||
'notes.toolbar.quote': '引用區塊',
|
||||
'notes.toolbar.codeBlock': '程式碼區塊',
|
||||
'notes.toolbar.mathFormula': '數學公式區塊 (LaTeX / Math)',
|
||||
'notes.toolbar.table': '插入表格',
|
||||
'notes.toolbar.divider': '分隔線',
|
||||
'notes.toolbar.link': '插入超連結',
|
||||
'notes.toolbar.image': '插入圖片',
|
||||
'notes.action.pin': '置頂筆記',
|
||||
'notes.action.unpin': '取消置頂',
|
||||
'notes.tag.remove': '移除標籤',
|
||||
'notes.tag.placeholder': '標籤名...',
|
||||
'notes.tag.add': '新增標籤',
|
||||
'notes.outline.title': '大綱目錄',
|
||||
'notes.outline.titleWithCount': '大綱目錄 ({count})',
|
||||
'notes.outline.empty': '暫無標題大綱',
|
||||
'notes.outline.emptyHint': '在 Markdown 中使用 #、## 編寫各級標題',
|
||||
'notes.export.share': '匯出與分享',
|
||||
'notes.export.currentNote': '目前筆記',
|
||||
'notes.export.exportMarkdown': '匯出為 Markdown (.md)',
|
||||
'notes.export.exportHtml': '匯出為 HTML (.html)',
|
||||
'notes.export.copyMarkdown': '複製 Markdown 源碼',
|
||||
'notes.export.allNotes': '全部筆記',
|
||||
'notes.export.exportAllZip': '匯出全部為 ZIP 壓縮包',
|
||||
'notes.export.toast.markdownSuccess': '已匯出 Markdown 檔案',
|
||||
'notes.export.toast.htmlSuccess': '已匯出 HTML 檔案',
|
||||
'notes.export.toast.zipSuccess': '已匯出全部筆記備份 ZIP',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '選擇主機',
|
||||
@@ -967,12 +912,12 @@ export const zhTWVaultMessages: Messages = {
|
||||
'sftp.conflict.title': '檔案衝突',
|
||||
'sftp.conflict.desc': '目標位置已存在同名檔案',
|
||||
'sftp.conflict.folderTitle': '資料夾衝突',
|
||||
'sftp.conflict.folderDesc': '請選擇處理方式。',
|
||||
'sftp.conflict.folderDesc': '目標位置已存在同名資料夾',
|
||||
'sftp.conflict.folderFileDesc': '目標位置已存在同名檔案,資料夾無法與檔案合併。',
|
||||
'sftp.conflict.folderSymlinkDesc': '目標位置已存在同名符號連結,資料夾無法與符號連結合併。',
|
||||
'sftp.conflict.folderUnknownDesc': '目標位置已存在同名項目,但無法確認其類型。為保護目標資料,替換操作不可用。',
|
||||
'sftp.conflict.folderMergeHint': '合併:保留目標中獨有的內容。',
|
||||
'sftp.conflict.folderReplaceWarning': '替換:刪除目標中獨有的內容,且無法復原。',
|
||||
'sftp.conflict.folderMergeHint': '合併會保留只存在於目標資料夾中的檔案和子資料夾。',
|
||||
'sftp.conflict.folderReplaceWarning': '替換會刪除目標資料夾中所有未出現在來源資料夾裡的檔案和子資料夾,刪除的資料無法復原。',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
'sftp.conflict.existingFile': '已有檔案',
|
||||
'sftp.conflict.newFile': '新檔案',
|
||||
|
||||
@@ -154,8 +154,7 @@ export const useAvailableFonts = (): TerminalFont[] => {
|
||||
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getAvailableFonts,
|
||||
fontStore.getAvailableFonts,
|
||||
fontStore.getAvailableFonts
|
||||
);
|
||||
};
|
||||
|
||||
@@ -166,7 +165,6 @@ export const useInstalledFontFamilies = (): string[] | null => {
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getInstalledFontFamilies,
|
||||
fontStore.getInstalledFontFamilies,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import { resolveInheritedAIActiveSessionId } from '../../domain/aiWorkspaceScopeInherit.ts';
|
||||
import { resolveDisplayedPanelView } from '../../components/ai/aiPanelViewState.ts';
|
||||
import type { AISession } from '../../infrastructure/ai/types.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
|
||||
const SCOPE_KEY = 'workspace:workspace-old';
|
||||
const RESTORED_SESSION: AISession = {
|
||||
id: 'chat-old',
|
||||
title: 'Restored chat',
|
||||
agentId: 'catty',
|
||||
scope: {
|
||||
type: 'terminal',
|
||||
targetId: 'terminal-a',
|
||||
hostIds: ['host-a'],
|
||||
},
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', content: 'old question', timestamp: 1 },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'old answer', timestamp: 2 },
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
};
|
||||
|
||||
function NewChatHarness() {
|
||||
const ai = useAIState();
|
||||
const state = useAISessionsStore();
|
||||
const visibleSessionIds = new Set(state.sessions.map((session) => session.id));
|
||||
const inheritedSessionId = resolveInheritedAIActiveSessionId({
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: 'workspace-old',
|
||||
activeSessionIdMap: state.activeSessionIdMap,
|
||||
memberTerminalIds: ['terminal-a', 'terminal-b'],
|
||||
preferredTerminalId: 'terminal-a',
|
||||
visibleSessionIds,
|
||||
});
|
||||
const view = resolveDisplayedPanelView(
|
||||
state.panelViewByScope[SCOPE_KEY],
|
||||
state.draftsByScope[SCOPE_KEY] != null,
|
||||
state.sessions as AISession[],
|
||||
inheritedSessionId,
|
||||
'workspace',
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-view={view.mode}
|
||||
data-session={view.mode === 'session' ? view.sessionId : ''}
|
||||
onClick={() => {
|
||||
ai.clearDraftForScope(SCOPE_KEY);
|
||||
ai.updateDraft(SCOPE_KEY, 'catty', () => ({
|
||||
text: '',
|
||||
agentId: 'catty',
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
ai.showDraftView(SCOPE_KEY);
|
||||
}}
|
||||
>
|
||||
New Chat
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
test('restored workspace enters a blank draft when New Chat is clicked', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: dom.window,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: dom.window.document,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: dom.window.localStorage,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||
configurable: true,
|
||||
value: dom.window.CustomEvent,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Event', {
|
||||
configurable: true,
|
||||
value: dom.window.Event,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify([RESTORED_SESSION]));
|
||||
dom.window.localStorage.setItem('netcatty_ai_active_session_map_v1', JSON.stringify({
|
||||
'terminal:terminal-a': RESTORED_SESSION.id,
|
||||
[SCOPE_KEY]: RESTORED_SESSION.id,
|
||||
}));
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<NewChatHarness />);
|
||||
});
|
||||
|
||||
const button = container.querySelector('button');
|
||||
assert.ok(button);
|
||||
assert.equal(button.dataset.view, 'session');
|
||||
assert.equal(button.dataset.session, RESTORED_SESSION.id);
|
||||
|
||||
await act(async () => {
|
||||
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
assert.equal(button.dataset.view, 'draft');
|
||||
assert.equal(button.dataset.session, '');
|
||||
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
});
|
||||
@@ -951,25 +951,34 @@ export function useAIState() {
|
||||
}, []);
|
||||
|
||||
const showDraftView = useCallback((scopeKey: string) => {
|
||||
const currentActiveSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const next = activateDraftView(
|
||||
currentActiveSessionIdMap,
|
||||
panelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let activeSessionMapChanged = false;
|
||||
let panelViewChanged = false;
|
||||
|
||||
if (next.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(next.activeSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next.activeSessionIdMap);
|
||||
setActiveSessionIdMapRaw(next.activeSessionIdMap);
|
||||
setActiveSessionIdMapRaw((prevActiveSessionIdMap) => {
|
||||
const next = activateDraftView(
|
||||
prevActiveSessionIdMap,
|
||||
currentPanelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
activeSessionMapChanged = next.activeSessionIdMap !== prevActiveSessionIdMap;
|
||||
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
||||
nextActiveSessionIdMap = next.activeSessionIdMap;
|
||||
nextPanelViewByScope = next.panelViewByScope;
|
||||
return activeSessionMapChanged ? next.activeSessionIdMap : prevActiveSessionIdMap;
|
||||
});
|
||||
|
||||
if (activeSessionMapChanged && nextActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (next.panelViewByScope !== panelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(next.panelViewByScope);
|
||||
setPanelViewByScopeRaw(next.panelViewByScope);
|
||||
if (panelViewChanged && nextPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
setPanelViewByScopeRaw(nextPanelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}, [panelViewByScope]);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createStoredNumberSyncHandler } from "./useStoredNumber.ts";
|
||||
|
||||
test("stored number instances adopt same-window adapter changes", () => {
|
||||
let current = 14;
|
||||
let received = 14;
|
||||
const handler = createStoredNumberSyncHandler({
|
||||
storageKey: "note-size",
|
||||
readValue: () => current,
|
||||
onValue: (value) => { received = value; },
|
||||
});
|
||||
|
||||
current = 18;
|
||||
handler(new CustomEvent("netcatty:local-storage-adapter-changed", { detail: { key: "other" } }));
|
||||
assert.equal(received, 14);
|
||||
handler(new CustomEvent("netcatty:local-storage-adapter-changed", { detail: { key: "note-size" } }));
|
||||
assert.equal(received, 18);
|
||||
});
|
||||
@@ -1,35 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const readStoredNumberValue = (
|
||||
storageKey: string,
|
||||
fallback: number,
|
||||
clamp?: { min: number; max: number },
|
||||
): number => {
|
||||
if (typeof globalThis.localStorage === "undefined") return fallback;
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
return stored;
|
||||
};
|
||||
|
||||
export const createStoredNumberSyncHandler = ({
|
||||
storageKey,
|
||||
readValue,
|
||||
onValue,
|
||||
}: {
|
||||
storageKey: string;
|
||||
readValue: () => number;
|
||||
onValue: (value: number) => void;
|
||||
}) => (event: Event) => {
|
||||
const key = event.type === "storage"
|
||||
? (event as StorageEvent).key
|
||||
: (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === storageKey) onValue(readValue());
|
||||
};
|
||||
import { useCallback, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for reading a number from localStorage with lazy persistence.
|
||||
@@ -43,32 +13,13 @@ export const useStoredNumber = (
|
||||
fallback: number,
|
||||
clamp?: { min: number; max: number },
|
||||
) => {
|
||||
const clampMin = clamp?.min;
|
||||
const clampMax = clamp?.max;
|
||||
const readValue = useCallback(
|
||||
() => readStoredNumberValue(
|
||||
storageKey,
|
||||
fallback,
|
||||
clampMin != null && clampMax != null ? { min: clampMin, max: clampMax } : undefined,
|
||||
),
|
||||
[clampMax, clampMin, fallback, storageKey],
|
||||
);
|
||||
const [value, setValue] = useState<number>(readValue);
|
||||
|
||||
useEffect(() => {
|
||||
const target = globalThis as typeof globalThis & {
|
||||
addEventListener?: (type: string, listener: EventListener) => void;
|
||||
removeEventListener?: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
if (typeof target.addEventListener !== "function") return;
|
||||
const sync = createStoredNumberSyncHandler({ storageKey, readValue, onValue: setValue });
|
||||
target.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
|
||||
target.addEventListener("storage", sync);
|
||||
return () => {
|
||||
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
|
||||
target.removeEventListener?.("storage", sync);
|
||||
};
|
||||
}, [readValue, storageKey]);
|
||||
const [value, setValue] = useState<number>(() => {
|
||||
if (typeof globalThis.localStorage === "undefined") return fallback;
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
return stored;
|
||||
});
|
||||
|
||||
const persist = useCallback(
|
||||
(v: number) => {
|
||||
|
||||
@@ -104,16 +104,3 @@ test("stored string helpers read fallback and resolve updater-style toggles", (t
|
||||
"edit",
|
||||
);
|
||||
});
|
||||
|
||||
test("stored string helpers work with default validator when isAllowedValue is omitted", (t) => {
|
||||
const env = installLocalStorage();
|
||||
t.after(() => env.restore());
|
||||
|
||||
const storageKey = "netcatty:test-font";
|
||||
assert.equal(readStoredStringValue(storageKey, "default-font"), "default-font");
|
||||
assert.equal(readOptionalStoredStringValue(storageKey), null);
|
||||
|
||||
env.storage.setItem(storageKey, "Inter");
|
||||
assert.equal(readStoredStringValue(storageKey, "default-font"), "Inter");
|
||||
assert.equal(readOptionalStoredStringValue(storageKey), "Inter");
|
||||
});
|
||||
@@ -8,27 +8,23 @@ type StoredStringSetter<T extends string> = (nextValue: T | ((currentValue: T) =
|
||||
|
||||
const canUseLocalStorage = () => typeof globalThis.localStorage !== "undefined";
|
||||
|
||||
const defaultIsAllowedString = <T extends string>(value: string | null): value is T => typeof value === "string";
|
||||
|
||||
export const readStoredStringValue = <T extends string = string>(
|
||||
export const readStoredStringValue = <T extends string>(
|
||||
storageKey: string,
|
||||
fallback: T,
|
||||
isAllowedValue: (value: string | null) => value is T = defaultIsAllowedString,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
): T => {
|
||||
if (!canUseLocalStorage()) return fallback;
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
return validator(stored) ? stored : fallback;
|
||||
return isAllowedValue(stored) ? stored : fallback;
|
||||
};
|
||||
|
||||
export const readOptionalStoredStringValue = <T extends string = string>(
|
||||
export const readOptionalStoredStringValue = <T extends string>(
|
||||
storageKey: string,
|
||||
isAllowedValue: (value: string | null) => value is T = defaultIsAllowedString,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
): T | null => {
|
||||
if (!canUseLocalStorage()) return null;
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
return validator(stored) ? stored : null;
|
||||
return isAllowedValue(stored) ? stored : null;
|
||||
};
|
||||
|
||||
export const resolveStoredStringUpdate = <T extends string>(
|
||||
@@ -51,12 +47,11 @@ export const createStoredStringSyncHandlers = <T extends string>({
|
||||
}: {
|
||||
storageKey: string;
|
||||
fallback: T;
|
||||
isAllowedValue?: (value: string | null) => value is T;
|
||||
isAllowedValue: (value: string | null) => value is T;
|
||||
onValue: (value: T) => void;
|
||||
}) => {
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
const syncFromStorage = () => {
|
||||
onValue(readStoredStringValue(storageKey, fallback, validator));
|
||||
onValue(readStoredStringValue(storageKey, fallback, isAllowedValue));
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -69,16 +64,15 @@ export const createStoredStringSyncHandlers = <T extends string>({
|
||||
};
|
||||
};
|
||||
|
||||
export const useStoredString = <T extends string = string>(
|
||||
export const useStoredString = <T extends string>(
|
||||
storageKey: string,
|
||||
fallback: T,
|
||||
isAllowedValue?: (value: string | null) => value is T,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
) => {
|
||||
const validator = isAllowedValue ?? defaultIsAllowedString;
|
||||
const [value, setValue] = useState<T>(() => readStoredStringValue(
|
||||
storageKey,
|
||||
fallback,
|
||||
validator,
|
||||
isAllowedValue,
|
||||
));
|
||||
|
||||
const setAndPersist = useCallback<StoredStringSetter<T>>((nextValue) => {
|
||||
@@ -104,7 +98,7 @@ export const useStoredString = <T extends string = string>(
|
||||
} = createStoredStringSyncHandlers({
|
||||
storageKey,
|
||||
fallback,
|
||||
isAllowedValue: validator,
|
||||
isAllowedValue,
|
||||
onValue: setValue,
|
||||
});
|
||||
|
||||
@@ -114,7 +108,7 @@ export const useStoredString = <T extends string = string>(
|
||||
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleAdapterChange);
|
||||
target.removeEventListener?.("storage", handleBrowserStorage);
|
||||
};
|
||||
}, [fallback, storageKey, validator]);
|
||||
}, [fallback, isAllowedValue, storageKey]);
|
||||
|
||||
return [value, setAndPersist] as const;
|
||||
};
|
||||
@@ -57,7 +57,9 @@ export const useWindowControls = () => {
|
||||
|
||||
const onFullscreenChanged = useCallback(subscribeWindowFullscreenChanged, []);
|
||||
|
||||
const onWindowCommandCloseRequested = useCallback((cb: () => void) => {
|
||||
const onWindowCommandCloseRequested = useCallback((
|
||||
cb: (request?: import('./windowCommandClose').WindowCommandCloseRequest) => void,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onWindowCommandCloseRequested?.(cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
@@ -1,15 +1,110 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { matchesKeyBinding } from "../../domain/models.ts";
|
||||
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from "./windowCommandClose.ts";
|
||||
import {
|
||||
resolveWindowCommandCloseIntent,
|
||||
resolveWindowCommandCloseRequestIntent,
|
||||
shouldHandleWindowCommandCloseRequest,
|
||||
} from "./windowCommandClose.ts";
|
||||
|
||||
test("primary-modifier W only belongs to the matching close-tab binding", () => {
|
||||
assert.equal(isPrimaryModifierWBinding("⌘ + W", matchesKeyBinding, true), true);
|
||||
assert.equal(isPrimaryModifierWBinding("⌘ + E", matchesKeyBinding, true), false);
|
||||
assert.equal(isPrimaryModifierWBinding("Ctrl + W", matchesKeyBinding, false), true);
|
||||
assert.equal(isPrimaryModifierWBinding("Disabled", matchesKeyBinding, true), false);
|
||||
assert.equal(isPrimaryModifierWBinding(null, matchesKeyBinding, true), false);
|
||||
const commandWRequest = {
|
||||
source: "keyboard" as const,
|
||||
input: {
|
||||
key: "w",
|
||||
code: "KeyW",
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
},
|
||||
};
|
||||
|
||||
test("keyboard close requests follow disabled, default, and rebound settings", () => {
|
||||
assert.equal(shouldHandleWindowCommandCloseRequest({
|
||||
request: commandWRequest,
|
||||
closeTabKeyStr: null,
|
||||
isMac: true,
|
||||
}), false);
|
||||
assert.equal(shouldHandleWindowCommandCloseRequest({
|
||||
request: commandWRequest,
|
||||
closeTabKeyStr: "Disabled",
|
||||
isMac: true,
|
||||
}), false);
|
||||
assert.equal(shouldHandleWindowCommandCloseRequest({
|
||||
request: commandWRequest,
|
||||
closeTabKeyStr: "⌘ + W",
|
||||
isMac: true,
|
||||
}), true);
|
||||
assert.equal(shouldHandleWindowCommandCloseRequest({
|
||||
request: commandWRequest,
|
||||
closeTabKeyStr: "⌘ + E",
|
||||
isMac: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("menu close requests remain available when the keyboard shortcut is disabled", () => {
|
||||
assert.equal(shouldHandleWindowCommandCloseRequest({
|
||||
closeTabKeyStr: null,
|
||||
isMac: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("disabled and rebound keyboard requests cannot close the Vault or a log view", () => {
|
||||
assert.equal(resolveWindowCommandCloseRequestIntent({
|
||||
request: commandWRequest,
|
||||
closeTabKeyStr: null,
|
||||
isMac: true,
|
||||
activeTabId: "vault",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}), null);
|
||||
assert.equal(resolveWindowCommandCloseRequestIntent({
|
||||
request: commandWRequest,
|
||||
closeTabKeyStr: "⌘ + E",
|
||||
isMac: true,
|
||||
activeTabId: "log-1",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: ["log-1"],
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("enabled keyboard requests preserve Vault and log-view close behavior", () => {
|
||||
assert.deepEqual(resolveWindowCommandCloseRequestIntent({
|
||||
request: commandWRequest,
|
||||
closeTabKeyStr: "⌘ + W",
|
||||
isMac: true,
|
||||
activeTabId: "vault",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}), { kind: "closeWindow" });
|
||||
assert.deepEqual(resolveWindowCommandCloseRequestIntent({
|
||||
request: commandWRequest,
|
||||
closeTabKeyStr: "⌘ + W",
|
||||
isMac: true,
|
||||
activeTabId: "log-1",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: ["log-1"],
|
||||
}), { kind: "closeLogView", tabId: "log-1" });
|
||||
});
|
||||
|
||||
test("menu requests preserve Vault close behavior with the shortcut disabled", () => {
|
||||
assert.deepEqual(resolveWindowCommandCloseRequestIntent({
|
||||
closeTabKeyStr: null,
|
||||
isMac: true,
|
||||
activeTabId: "vault",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
}), { kind: "closeWindow" });
|
||||
});
|
||||
|
||||
test("Cmd+W closes the active closable tab first", () => {
|
||||
@@ -25,92 +120,6 @@ test("Cmd+W closes the active closable tab first", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled close-tab binding forwards native Cmd+W to other custom shortcuts", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "s1",
|
||||
editorTabIds: [],
|
||||
sessionIds: ["s1"],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
closeTabShortcutEnabled: false,
|
||||
}),
|
||||
{ kind: "forwardShortcut" },
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled close-tab binding forwards native Cmd+W while a dialog is open", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "s1",
|
||||
editorTabIds: [],
|
||||
sessionIds: ["s1"],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
closeTabShortcutEnabled: false,
|
||||
hasOpenDialog: true,
|
||||
}),
|
||||
{ kind: "forwardShortcut" },
|
||||
);
|
||||
});
|
||||
|
||||
test("enabled close-tab binding closes the topmost dialog before the active tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "s1",
|
||||
editorTabIds: [],
|
||||
sessionIds: ["s1"],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
closeTabShortcutEnabled: true,
|
||||
hasOpenDialog: true,
|
||||
}),
|
||||
{ kind: "closeDialog" },
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled close-tab binding also forwards native Cmd+W on a log tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "log-1",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: ["log-1"],
|
||||
closeTabShortcutEnabled: false,
|
||||
}),
|
||||
{ kind: "forwardShortcut" },
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled close-tab binding forwards native Cmd+W instead of closing the window", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: "vault",
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
closeTabShortcutEnabled: false,
|
||||
}),
|
||||
{ kind: "forwardShortcut" },
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled close-tab binding forwards native Cmd+W when nothing is active", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
activeTabId: null,
|
||||
editorTabIds: [],
|
||||
sessionIds: [],
|
||||
workspaceIds: [],
|
||||
logViewIds: [],
|
||||
closeTabShortcutEnabled: false,
|
||||
}),
|
||||
{ kind: "forwardShortcut" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Cmd+W on a log view closes the log view", () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCommandCloseIntent({
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import { matchesKeyBinding } from '../../domain/models/keyBindings';
|
||||
|
||||
export interface WindowCommandCloseRequest {
|
||||
source?: 'keyboard';
|
||||
input?: {
|
||||
key: string;
|
||||
code: string;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type WindowCommandCloseIntent =
|
||||
| { kind: 'forwardShortcut' }
|
||||
| { kind: 'closeDialog' }
|
||||
| { kind: 'closeTab' }
|
||||
| { kind: 'closeLogView'; tabId: string }
|
||||
| { kind: 'closeWindow' };
|
||||
|
||||
type KeyBindingMatcher = (event: KeyboardEvent, binding: string, isMac: boolean) => boolean;
|
||||
|
||||
export function isPrimaryModifierWBinding(
|
||||
binding: string | null,
|
||||
matcher: KeyBindingMatcher,
|
||||
isMac: boolean,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
binding
|
||||
&& matcher({
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
} as KeyboardEvent, binding, isMac),
|
||||
);
|
||||
}
|
||||
|
||||
interface ResolveWindowCommandCloseIntentInput {
|
||||
activeTabId: string | null;
|
||||
editorTabIds: string[];
|
||||
@@ -32,8 +24,20 @@ interface ResolveWindowCommandCloseIntentInput {
|
||||
workspaceIds: string[];
|
||||
logViewIds: string[];
|
||||
pluginViewTabIds?: string[];
|
||||
closeTabShortcutEnabled?: boolean;
|
||||
hasOpenDialog?: boolean;
|
||||
}
|
||||
|
||||
export function shouldHandleWindowCommandCloseRequest({
|
||||
request,
|
||||
closeTabKeyStr,
|
||||
isMac,
|
||||
}: {
|
||||
request?: WindowCommandCloseRequest;
|
||||
closeTabKeyStr: string | null;
|
||||
isMac: boolean;
|
||||
}): boolean {
|
||||
if (request?.source !== 'keyboard') return true;
|
||||
if (!request.input || !closeTabKeyStr) return false;
|
||||
return matchesKeyBinding(request.input as KeyboardEvent, closeTabKeyStr, isMac);
|
||||
}
|
||||
|
||||
export function resolveWindowCommandCloseIntent({
|
||||
@@ -43,17 +47,7 @@ export function resolveWindowCommandCloseIntent({
|
||||
workspaceIds,
|
||||
logViewIds,
|
||||
pluginViewTabIds = [],
|
||||
closeTabShortcutEnabled = true,
|
||||
hasOpenDialog = false,
|
||||
}: ResolveWindowCommandCloseIntentInput): WindowCommandCloseIntent {
|
||||
if (!closeTabShortcutEnabled) {
|
||||
return { kind: 'forwardShortcut' };
|
||||
}
|
||||
|
||||
if (hasOpenDialog) {
|
||||
return { kind: 'closeDialog' };
|
||||
}
|
||||
|
||||
if (!activeTabId) {
|
||||
return { kind: 'closeWindow' };
|
||||
}
|
||||
@@ -76,3 +70,19 @@ export function resolveWindowCommandCloseIntent({
|
||||
|
||||
return { kind: 'closeWindow' };
|
||||
}
|
||||
|
||||
export function resolveWindowCommandCloseRequestIntent({
|
||||
request,
|
||||
closeTabKeyStr,
|
||||
isMac,
|
||||
...intentInput
|
||||
}: ResolveWindowCommandCloseIntentInput & {
|
||||
request?: WindowCommandCloseRequest;
|
||||
closeTabKeyStr: string | null;
|
||||
isMac: boolean;
|
||||
}): WindowCommandCloseIntent | null {
|
||||
if (!shouldHandleWindowCommandCloseRequest({ request, closeTabKeyStr, isMac })) {
|
||||
return null;
|
||||
}
|
||||
return resolveWindowCommandCloseIntent(intentInput);
|
||||
}
|
||||
@@ -292,30 +292,6 @@ test("terminal side panel auto-open settings are syncable for auto-sync detectio
|
||||
);
|
||||
});
|
||||
|
||||
test("note appearance settings survive sync and trigger auto-sync", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, "Menlo, monospace");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE, "16");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, "14");
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
assert.equal(payload.settings?.noteFontFamily, "Menlo, monospace");
|
||||
assert.equal(payload.settings?.noteFontSize, 16);
|
||||
assert.equal(payload.settings?.noteCodeFontSize, 14);
|
||||
for (const key of [
|
||||
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
]) {
|
||||
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
|
||||
}
|
||||
|
||||
localStorage.clear();
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_FAMILY), "Menlo, monospace");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_FONT_SIZE), "16");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
|
||||
|
||||
@@ -108,9 +108,6 @@ import {
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
|
||||
import { prepareRestoredPayloadConvergentWrites } from './convergentSyncReplica';
|
||||
@@ -278,9 +275,6 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
@@ -438,12 +432,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (lang) settings.uiLanguage = lang;
|
||||
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
|
||||
if (css != null) settings.customCSS = css;
|
||||
const noteFontFamily = localStorageAdapter.readString(STORAGE_KEY_VAULT_NOTES_FONT_FAMILY);
|
||||
if (noteFontFamily != null) settings.noteFontFamily = noteFontFamily;
|
||||
const noteFontSize = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_NOTES_FONT_SIZE);
|
||||
if (noteFontSize != null && noteFontSize >= 10 && noteFontSize <= 32) settings.noteFontSize = noteFontSize;
|
||||
const noteCodeFontSize = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE);
|
||||
if (noteCodeFontSize != null && noteCodeFontSize >= 10 && noteCodeFontSize <= 32) settings.noteCodeFontSize = noteCodeFontSize;
|
||||
|
||||
// Terminal
|
||||
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
@@ -667,13 +655,6 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
||||
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
||||
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
||||
if (settings.noteFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, settings.noteFontFamily);
|
||||
if (settings.noteFontSize != null && settings.noteFontSize >= 10 && settings.noteFontSize <= 32) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_NOTES_FONT_SIZE, settings.noteFontSize);
|
||||
}
|
||||
if (settings.noteCodeFontSize != null && settings.noteCodeFontSize >= 10 && settings.noteCodeFontSize <= 32) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, settings.noteCodeFontSize);
|
||||
}
|
||||
|
||||
// Terminal
|
||||
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
|
||||
|
||||
@@ -25,8 +25,6 @@ import { useExternalMcpGrantPersister } from "./ai/useExternalMcpGrantPersister"
|
||||
import { setupMcpApprovalBridge } from "../infrastructure/ai/shared/approvalGate";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
import { PluginContributionHost } from "./plugins/PluginContributionHost";
|
||||
import { matchesKeyBinding } from "../domain/models";
|
||||
import { isPrimaryModifierWBinding } from "../application/state/windowCommandClose";
|
||||
|
||||
const LazySettingsApplicationTab = lazy(() => import("./SettingsApplicationTab"));
|
||||
const LazySettingsAppearanceTab = lazy(() => import("./settings/tabs/SettingsAppearanceTab"));
|
||||
@@ -316,13 +314,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
const { available: pluginRuntimeAvailable } = usePluginContributions();
|
||||
const closeTabKeyStr = useMemo(() => {
|
||||
if (settings.hotkeyScheme === "disabled") return null;
|
||||
const binding = settings.keyBindings.find((item) => item.action === "closeTab");
|
||||
if (!binding) return null;
|
||||
return settings.hotkeyScheme === "mac" ? binding.mac : binding.pc;
|
||||
}, [settings.hotkeyScheme, settings.keyBindings]);
|
||||
const nativeCommandWClosesSettings = isPrimaryModifierWBinding(closeTabKeyStr, matchesKeyBinding, true);
|
||||
|
||||
useEffect(() => {
|
||||
notifyRendererReady();
|
||||
@@ -347,20 +338,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onWindowCommandCloseRequested(() => {
|
||||
if (!nativeCommandWClosesSettings) {
|
||||
(document.activeElement ?? window).dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "w",
|
||||
code: "KeyW",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
void closeSettingsWindow();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [closeSettingsWindow, nativeCommandWClosesSettings, onWindowCommandCloseRequested]);
|
||||
}, [closeSettingsWindow, onWindowCommandCloseRequested]);
|
||||
|
||||
useEffect(() => {
|
||||
setMountedTabs((prev) => {
|
||||
|
||||
@@ -6,12 +6,12 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
canPromoteTextEditor,
|
||||
getTextEditorContentStats,
|
||||
isTextEditorCommandWEnabled,
|
||||
isTextEditorReadOnly,
|
||||
shouldHandleMonacoCloseCommand,
|
||||
TextEditorPromoteButton,
|
||||
} from "./TextEditorPane.tsx";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../domain/models/keyBindings.ts";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
|
||||
const wrap = (child: React.ReactElement) =>
|
||||
React.createElement(TooltipProvider, null, child);
|
||||
@@ -52,18 +52,28 @@ test("counts editor content without allocating line arrays", () => {
|
||||
assert.deepEqual(getTextEditorContentStats("one\ntwo\n"), { lineCount: 3, charCount: 8 });
|
||||
});
|
||||
|
||||
test("Monaco Cmd+W follows the current close-tab binding", () => {
|
||||
test("Monaco close command follows disabled, default, and rebound close-tab settings", () => {
|
||||
const closeTabBinding = DEFAULT_KEY_BINDINGS.find((binding) => binding.action === "closeTab");
|
||||
assert.ok(closeTabBinding);
|
||||
|
||||
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "mac", closeTabBinding, isMac: true }), true);
|
||||
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "pc", closeTabBinding, isMac: false }), true);
|
||||
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "pc", closeTabBinding, isMac: true }), false);
|
||||
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "mac", closeTabBinding, isMac: false }), false);
|
||||
assert.equal(isTextEditorCommandWEnabled({ hotkeyScheme: "disabled", closeTabBinding, isMac: true }), false);
|
||||
assert.equal(isTextEditorCommandWEnabled({
|
||||
assert.equal(shouldHandleMonacoCloseCommand({
|
||||
hotkeyScheme: "mac",
|
||||
closeTabBinding: { ...closeTabBinding, mac: "Disabled" },
|
||||
macPlatform: true,
|
||||
}), false);
|
||||
assert.equal(shouldHandleMonacoCloseCommand({
|
||||
hotkeyScheme: "mac",
|
||||
closeTabBinding,
|
||||
macPlatform: true,
|
||||
}), true);
|
||||
assert.equal(shouldHandleMonacoCloseCommand({
|
||||
hotkeyScheme: "mac",
|
||||
closeTabBinding: { ...closeTabBinding, mac: "⌘ + E" },
|
||||
isMac: true,
|
||||
macPlatform: true,
|
||||
}), false);
|
||||
assert.equal(shouldHandleMonacoCloseCommand({
|
||||
hotkeyScheme: "disabled",
|
||||
closeTabBinding,
|
||||
macPlatform: true,
|
||||
}), false);
|
||||
});
|
||||
@@ -21,15 +21,14 @@ const monacoBasePath = viteEnv.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${viteEnv.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
const isMacPlatform = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
|
||||
import { isPrimaryModifierWBinding } from '../../application/state/windowCommandClose';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste';
|
||||
import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme';
|
||||
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
|
||||
import { isMacPlatform } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
@@ -118,21 +117,26 @@ export function getTextEditorContentStats(content: string): { lineCount: number;
|
||||
return { lineCount, charCount: content.length };
|
||||
}
|
||||
|
||||
export function isTextEditorCommandWEnabled({
|
||||
export function shouldHandleMonacoCloseCommand({
|
||||
hotkeyScheme,
|
||||
closeTabBinding,
|
||||
isMac,
|
||||
macPlatform,
|
||||
}: {
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
closeTabBinding?: KeyBinding;
|
||||
isMac: boolean;
|
||||
closeTabBinding: KeyBinding | undefined;
|
||||
macPlatform: boolean;
|
||||
}): boolean {
|
||||
if (hotkeyScheme === 'disabled' || !closeTabBinding) return false;
|
||||
return isPrimaryModifierWBinding(
|
||||
hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc,
|
||||
matchesKeyBinding,
|
||||
isMac,
|
||||
);
|
||||
const isMacScheme = hotkeyScheme === 'mac';
|
||||
const keyStr = isMacScheme ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
return matchesKeyBinding({
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
metaKey: macPlatform,
|
||||
ctrlKey: !macPlatform,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
} as KeyboardEvent, keyStr, isMacScheme);
|
||||
}
|
||||
|
||||
export const TextEditorPromoteButton: React.FC<{
|
||||
@@ -185,7 +189,6 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
// 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 closeTabCommandWEnabledRef = useRef(false);
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
@@ -193,11 +196,8 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
() => keyBindings.find((binding) => binding.action === 'closeTab'),
|
||||
[keyBindings],
|
||||
);
|
||||
closeTabCommandWEnabledRef.current = isTextEditorCommandWEnabled({
|
||||
hotkeyScheme,
|
||||
closeTabBinding,
|
||||
isMac: isMacPlatform,
|
||||
});
|
||||
const closeTabShortcutRef = useRef({ hotkeyScheme, closeTabBinding });
|
||||
closeTabShortcutRef.current = { hotkeyScheme, closeTabBinding };
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (saving) return;
|
||||
@@ -288,12 +288,14 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
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.
|
||||
// Monaco handles the platform's default close key before the pane's DOM
|
||||
// capture handler. Keep this fallback, but consult the live shortcut
|
||||
// setting so a disabled or reassigned close-tab binding is not bypassed.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
|
||||
if (!closeTabCommandWEnabledRef.current) return;
|
||||
if (!shouldHandleMonacoCloseCommand({
|
||||
...closeTabShortcutRef.current,
|
||||
macPlatform: isMacPlatform(),
|
||||
})) return;
|
||||
handleCloseRef.current?.();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import "./noteMath.scss";
|
||||
|
||||
export { InlineMarkdownEditor } from "./InlineMarkdownEditor";
|
||||
@@ -1,219 +1,18 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import {
|
||||
annotateNoteCodeBlockDeleteButtons,
|
||||
createNoteDecorationMutationScheduler,
|
||||
getHostPickerTriggerRange,
|
||||
getNoteDecorationMutationDelay,
|
||||
getRenderedNoteHeadingText,
|
||||
isNoteSmallImageWidth,
|
||||
isNoteMathLanguageLabel,
|
||||
isSupportedNoteExternalHref,
|
||||
isPointerInsideLinkActionHoverZone,
|
||||
invokeNoteEditorDialogAction,
|
||||
linkActionStatesEqual,
|
||||
NOTE_SMALL_IMAGE_MAX_WIDTH,
|
||||
NOTE_EDIT_DECORATION_DEBOUNCE_MS,
|
||||
resolveHostPickerPopupPosition,
|
||||
scrollNoteHeadingIntoView,
|
||||
shouldApplyExternalNoteMarkdown,
|
||||
shouldRenderNoteMathFormula,
|
||||
shouldInsertClipboardTextAsMarkdown,
|
||||
shouldHandleHostPickerNavigationKey,
|
||||
} from "./InlineMarkdownEditor.tsx";
|
||||
import { getSourceEditDelta, shouldCoalesceSourceUndoStep } from "./NoteSourceEditor.tsx";
|
||||
|
||||
test("source undo history coalesces only adjacent edits of the same typing kind", () => {
|
||||
const previous = { inputType: "insertText", at: 1_000, caret: 2 };
|
||||
const adjacentInsert = getSourceEditDelta("ab", "abc");
|
||||
const movedCaretInsert = getSourceEditDelta("ab", "axb");
|
||||
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_500, adjacentInsert), true);
|
||||
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_500, movedCaretInsert), false);
|
||||
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertText", 1_751, adjacentInsert), false);
|
||||
assert.equal(shouldCoalesceSourceUndoStep(previous, "deleteContentBackward", 1_100, adjacentInsert), false);
|
||||
assert.equal(shouldCoalesceSourceUndoStep(previous, "insertFromPaste", 1_100, adjacentInsert), false);
|
||||
assert.equal(shouldCoalesceSourceUndoStep(null, "insertText", 1_100, adjacentInsert), false);
|
||||
});
|
||||
|
||||
test("math language detection does not mistake plain text blocks for TeX", () => {
|
||||
assert.equal(isNoteMathLanguageLabel("math"), false);
|
||||
assert.equal(isNoteMathLanguageLabel("Math (LaTeX)"), false);
|
||||
assert.equal(isNoteMathLanguageLabel("language-latex"), true);
|
||||
assert.equal(isNoteMathLanguageLabel("language-tex highlighted"), true);
|
||||
assert.equal(isNoteMathLanguageLabel("latex"), true);
|
||||
assert.equal(isNoteMathLanguageLabel("tex"), true);
|
||||
assert.equal(isNoteMathLanguageLabel("公式"), false);
|
||||
assert.equal(isNoteMathLanguageLabel("text"), false);
|
||||
assert.equal(isNoteMathLanguageLabel("plaintext"), false);
|
||||
assert.equal(isNoteMathLanguageLabel("typescript"), false);
|
||||
assert.equal(shouldRenderNoteMathFormula("Plain text"), false);
|
||||
assert.equal(shouldRenderNoteMathFormula("plaintext"), false);
|
||||
assert.equal(shouldRenderNoteMathFormula(""), false);
|
||||
assert.equal(shouldRenderNoteMathFormula("math"), false);
|
||||
assert.equal(shouldRenderNoteMathFormula("latex"), true);
|
||||
assert.equal(shouldRenderNoteMathFormula("tex"), true);
|
||||
});
|
||||
|
||||
test("live note decoration scans are debounced while preview mounts stay immediate", () => {
|
||||
assert.equal(getNoteDecorationMutationDelay("edit"), NOTE_EDIT_DECORATION_DEBOUNCE_MS);
|
||||
assert.equal(getNoteDecorationMutationDelay("live"), NOTE_EDIT_DECORATION_DEBOUNCE_MS);
|
||||
assert.equal(getNoteDecorationMutationDelay("preview"), 0);
|
||||
|
||||
let nextId = 1;
|
||||
let runCount = 0;
|
||||
const timers = new Map<number, () => void>();
|
||||
const frames = new Map<number, () => void>();
|
||||
const runtime = {
|
||||
requestFrame: (callback: () => void) => {
|
||||
const id = nextId++;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
cancelFrame: (id: number) => { frames.delete(id); },
|
||||
setTimer: (callback: () => void, delay: number) => {
|
||||
assert.equal(delay, NOTE_EDIT_DECORATION_DEBOUNCE_MS);
|
||||
const id = nextId++;
|
||||
timers.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id: number) => { timers.delete(id); },
|
||||
};
|
||||
|
||||
const editScheduler = createNoteDecorationMutationScheduler("edit", () => { runCount += 1; }, runtime);
|
||||
editScheduler.schedule();
|
||||
editScheduler.schedule();
|
||||
editScheduler.schedule();
|
||||
assert.equal(runCount, 0);
|
||||
assert.equal(timers.size, 1);
|
||||
const pendingEdit = [...timers.values()][0];
|
||||
timers.clear();
|
||||
pendingEdit();
|
||||
assert.equal(runCount, 1);
|
||||
|
||||
editScheduler.schedule();
|
||||
assert.equal(timers.size, 1);
|
||||
const cancelledEdit = [...timers.values()][0];
|
||||
editScheduler.cancel();
|
||||
assert.equal(timers.size, 0);
|
||||
cancelledEdit();
|
||||
assert.equal(runCount, 1);
|
||||
|
||||
const previewScheduler = createNoteDecorationMutationScheduler("preview", () => { runCount += 1; }, runtime);
|
||||
previewScheduler.schedule();
|
||||
previewScheduler.schedule();
|
||||
assert.equal(frames.size, 1);
|
||||
const pendingPreview = [...frames.values()][0];
|
||||
frames.clear();
|
||||
pendingPreview();
|
||||
assert.equal(runCount, 2);
|
||||
});
|
||||
|
||||
test("note link and image actions open the editor dialogs", () => {
|
||||
const opened: string[] = [];
|
||||
const dialogs = {
|
||||
openImageDialog: () => opened.push("image"),
|
||||
openLinkDialog: () => opened.push("link"),
|
||||
};
|
||||
|
||||
assert.equal(invokeNoteEditorDialogAction("link", dialogs), true);
|
||||
assert.equal(invokeNoteEditorDialogAction("image", dialogs), true);
|
||||
assert.equal(invokeNoteEditorDialogAction("bold", dialogs), false);
|
||||
assert.equal(invokeNoteEditorDialogAction("link", null), false);
|
||||
assert.deepEqual(opened, ["link", "image"]);
|
||||
});
|
||||
|
||||
test("toolbar text selection is restricted to the note editor", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /if \(domText\) \{/);
|
||||
assert.match(source, /domSelection && domSelection\.rangeCount > 0 && container/);
|
||||
assert.match(source, /container\.contains\(range\.startContainer\)/);
|
||||
assert.match(source, /container\.contains\(range\.endContainer\)/);
|
||||
assert.match(source, /return domText;[\s\S]*return "";[\s\S]*querySelector\("\[contenteditable\]"\)/);
|
||||
});
|
||||
|
||||
test("note outline jumps to the matching rendered heading", () => {
|
||||
let scrollOptions: ScrollIntoViewOptions | undefined;
|
||||
const first = {
|
||||
tagName: "H1",
|
||||
textContent: "Quoted",
|
||||
scrollIntoView: () => undefined,
|
||||
} as unknown as HTMLElement;
|
||||
const second = {
|
||||
tagName: "H2",
|
||||
textContent: "Real",
|
||||
scrollIntoView: (options?: ScrollIntoViewOptions) => {
|
||||
scrollOptions = options;
|
||||
},
|
||||
} as unknown as HTMLElement;
|
||||
const root = {
|
||||
querySelectorAll: (selector: string) => {
|
||||
assert.match(selector, /\.netcatty-mdx-content h1/);
|
||||
assert.match(selector, /\.netcatty-mdx-content h6/);
|
||||
return [first, second];
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Real" }), true);
|
||||
assert.deepEqual(scrollOptions, {
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
inline: "nearest",
|
||||
});
|
||||
assert.equal(scrollNoteHeadingIntoView(root, { level: 3, text: "Missing" }), false);
|
||||
assert.equal(scrollNoteHeadingIntoView(null, { level: 1, text: "None" }), false);
|
||||
});
|
||||
|
||||
test("note outline matches rendered whitespace and strikethrough heading text", () => {
|
||||
let scrolled = false;
|
||||
const renderedHeading = {
|
||||
tagName: "H2",
|
||||
textContent: "Removed\n title",
|
||||
scrollIntoView: () => {
|
||||
scrolled = true;
|
||||
},
|
||||
} as unknown as HTMLElement;
|
||||
const root = {
|
||||
querySelectorAll: () => [renderedHeading],
|
||||
};
|
||||
|
||||
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Removed title" }), true);
|
||||
assert.equal(scrolled, true);
|
||||
});
|
||||
|
||||
test("note outline matches image alt text inside rendered headings", () => {
|
||||
const textNode = (textContent: string) => ({ nodeType: 3, textContent, childNodes: [] });
|
||||
const imageNode = (alt: string) => ({
|
||||
nodeType: 1,
|
||||
tagName: "IMG",
|
||||
textContent: "",
|
||||
childNodes: [],
|
||||
getAttribute: (name: string) => name === "alt" ? alt : null,
|
||||
});
|
||||
let scrolled = 0;
|
||||
const imageHeading = {
|
||||
tagName: "H2",
|
||||
textContent: "",
|
||||
childNodes: [imageNode("Logo")],
|
||||
scrollIntoView: () => { scrolled += 1; },
|
||||
} as unknown as HTMLElement;
|
||||
const mixedHeading = {
|
||||
tagName: "H2",
|
||||
textContent: "Release notes",
|
||||
childNodes: [textNode("Release "), imageNode("Logo"), textNode(" notes")],
|
||||
scrollIntoView: () => { scrolled += 1; },
|
||||
} as unknown as HTMLElement;
|
||||
const root = { querySelectorAll: () => [imageHeading, mixedHeading] };
|
||||
|
||||
assert.equal(getRenderedNoteHeadingText(imageHeading), "Logo");
|
||||
assert.equal(getRenderedNoteHeadingText(mixedHeading), "Release Logo notes");
|
||||
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Logo" }), true);
|
||||
assert.equal(scrollNoteHeadingIntoView(root, { level: 2, text: "Release Logo notes" }), true);
|
||||
assert.equal(scrolled, 2);
|
||||
});
|
||||
|
||||
test("host picker navigation keys are handled even before a query is typed", () => {
|
||||
assert.equal(shouldHandleHostPickerNavigationKey(true, "ArrowDown", 3), true);
|
||||
@@ -275,35 +74,6 @@ test("small note image width threshold matches README badge sizes", () => {
|
||||
assert.equal(isNoteSmallImageWidth(null), false);
|
||||
});
|
||||
|
||||
test("note image actions use a bordered toolbar shown only on hover or focus", () => {
|
||||
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\][\s\S]*?gap:\s*0\.0625rem;[\s\S]*?padding:\s*0\.125rem;[\s\S]*?opacity:\s*0;[\s\S]*?pointer-events:\s*none;[\s\S]*?border:\s*1px solid/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\] button,[\s\S]*?width:\s*1\.375rem;[\s\S]*?height:\s*1\.375rem;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\] \[class\*="_editImageToolbar_"\] button svg,[\s\S]*?width:\s*0\.875rem;[\s\S]*?height:\s*0\.875rem;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\]:hover \[class\*="_editImageToolbar_"\][\s\S]*?opacity:\s*1;[\s\S]*?pointer-events:\s*auto;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\]:focus-within \[class\*="_editImageToolbar_"\]/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
styles,
|
||||
/\[data-editor-block-type="image"\]\[data-note-img-size="sm"\] \[class\*="_editImageToolbar_"\]/,
|
||||
);
|
||||
});
|
||||
|
||||
test("host picker trigger range only covers the typed trigger and query", () => {
|
||||
const text = "before\n\n@10.2.0.32";
|
||||
const range = getHostPickerTriggerRange(text);
|
||||
@@ -374,43 +144,37 @@ test("note editor registers a code block editor for pasted fenced code", () => {
|
||||
test("note editor enables image plugin for remote markdown images", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /imagePlugin\(\{\s*allowSetImageDimensions:\s*true/);
|
||||
assert.match(source, /InsertImage/);
|
||||
});
|
||||
|
||||
test("note editor exposes its modes from a borderless title-row dropdown", () => {
|
||||
test("note editor exposes preview and edit modes with a markdown toolbar in edit mode", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
const toolbarSource = readFileSync(new URL("./NoteToolbar.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /type NoteEditorMode/);
|
||||
// The app-owned NoteToolbar (not MDXEditor's toolbarPlugin) hosts the
|
||||
// formatting controls; MDXEditor must not render its own toolbar.
|
||||
assert.doesNotMatch(source, /toolbarPlugin\(/);
|
||||
assert.match(toolbarSource, /onAction\?\.\("undo"\)/);
|
||||
assert.match(toolbarSource, /onAction\?\.\("redo"\)/);
|
||||
assert.match(toolbarSource, /<Undo2 size=\{14\} \/>/);
|
||||
assert.match(toolbarSource, /<Redo2 size=\{14\} \/>/);
|
||||
assert.match(source, /type NoteEditorMode = "edit" \| "preview"/);
|
||||
assert.match(source, /toolbarPlugin\(\{\s*toolbarContents:/s);
|
||||
// Preview and edit both use MDXEditor (readOnly in preview).
|
||||
assert.match(source, /readOnly=\{editorMode === "preview"\}/);
|
||||
assert.match(source, /key=\{editorMode\}/);
|
||||
assert.match(source, /netcatty-mdx-editor--preview/);
|
||||
assert.doesNotMatch(source, /NoteMarkdownPreview|react-markdown|github-markdown/);
|
||||
assert.match(source, /<BlockTypeSelect \/>/);
|
||||
assert.match(source, /<BoldItalicUnderlineToggles /);
|
||||
assert.match(source, /<ListsToggle /);
|
||||
assert.match(source, /<InsertCodeBlock \/>/);
|
||||
assert.match(source, /<InsertTable \/>/);
|
||||
assert.match(source, /editorMode = controlledEditorMode \?\? "edit"/);
|
||||
assert.doesNotMatch(source, /data-note-mode-switch/);
|
||||
assert.doesNotMatch(source, /absolute -top-9/);
|
||||
assert.match(managerSource, /data-note-title-row/);
|
||||
assert.match(toolbarSource, /data-note-mode-dropdown-trigger/);
|
||||
assert.match(toolbarSource, /data-note-mode-option=\{option\.mode\}/);
|
||||
assert.match(toolbarSource, /gap-1\.5 border-0 bg-transparent px-2/);
|
||||
assert.match(toolbarSource, /<SelectContent align="end" className="w-max min-w-\[10rem\]">/);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/data-note-mode-option=\{option\.mode\}[\s\S]*?className="h-9 whitespace-nowrap"/,
|
||||
);
|
||||
assert.match(toolbarSource, /<Select value=\{normalizedMode\}/);
|
||||
assert.doesNotMatch(toolbarSource, /data-note-mode-switch=/);
|
||||
assert.match(managerSource, /data-note-title-row[\s\S]*?<NoteModeDropdown[\s\S]*?<NoteToolbar/);
|
||||
assert.match(managerSource, /<NoteModeDropdown[\s\S]*?editorMode=\{noteEditorMode\}/);
|
||||
assert.match(managerSource, /data-note-mode-switch/);
|
||||
assert.match(managerSource, /Glasses/);
|
||||
assert.match(managerSource, /PencilLine/);
|
||||
assert.match(managerSource, /setNoteEditorMode\(\(currentMode\) => \(/);
|
||||
assert.match(managerSource, /currentMode === "edit" \? "preview" : "edit"/);
|
||||
assert.match(managerSource, /editorMode=\{noteEditorMode\}/);
|
||||
assert.match(managerSource, /className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary\/70 hover:text-foreground"/);
|
||||
assert.doesNotMatch(managerSource, /data-note-mode-switch[\s\S]{0,500}border/);
|
||||
assert.doesNotMatch(`${source}\n${managerSource}`, /role="tablist"|role="tab"|renderModeButton/);
|
||||
assert.doesNotMatch(`${source}\n${managerSource}`, /className="mb-2 flex shrink-0 items-center justify-end"/);
|
||||
});
|
||||
@@ -418,7 +182,6 @@ test("note editor exposes its modes from a borderless title-row dropdown", () =>
|
||||
test("note markdown toolbar remains usable in narrow panes", () => {
|
||||
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
const toolbarSource = readFileSync(new URL("./NoteToolbar.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /MoreHorizontal|data-note-toolbar-more|netcatty-note-toolbar-more/);
|
||||
|
||||
@@ -432,23 +195,29 @@ test("note markdown toolbar remains usable in narrow panes", () => {
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s*\{[^}]*transform:\s*translateZ\(0\);/s,
|
||||
);
|
||||
// The app-owned NoteToolbar scrolls horizontally when the pane is narrow so
|
||||
// formatting buttons are never clipped; the scrollbar stays visible.
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/overflow-x-auto [^"]*\[scrollbar-width:thin\]/,
|
||||
styles,
|
||||
/\.netcatty-note-markdown-toolbar\s*\{[^}]*max-width:\s*100%;[^}]*height:\s*auto\s*!important;[^}]*overflow:\s*visible\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/\[&::-webkit-scrollbar\]:h-1\.5/,
|
||||
styles,
|
||||
/\.netcatty-note-markdown-toolbar\s*\{[^}]*box-sizing:\s*border-box;[^}]*display:\s*flex\s*!important;[^}]*flex-wrap:\s*wrap\s*!important;[^}]*align-content:\s*flex-start\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/\[&::-webkit-scrollbar-thumb\]:bg-border\/70/,
|
||||
styles,
|
||||
/\.netcatty-note-markdown-toolbar\s*>\s*\*\s*\{[^}]*flex-shrink:\s*0;/s,
|
||||
);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/flex flex-1 items-center gap-0\.5 min-w-0 overflow-x-auto/,
|
||||
styles,
|
||||
/@container\s*\(max-width:\s*34rem\)\s*\{[\s\S]*\.netcatty-note-markdown-toolbar\s*\{[^}]*gap:\s*0\.125rem\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container\s*\(max-width:\s*34rem\)\s*\{[\s\S]*\.netcatty-note-markdown-toolbar\s*\{[^}]*flex-wrap:\s*wrap\s*!important;[^}]*align-content:\s*flex-start\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container\s*\(max-width:\s*34rem\)\s*\{[\s\S]*\.netcatty-note-markdown-toolbar\s+button,[\s\S]*\.netcatty-note-markdown-toolbar\s+\[role="button"\][\s\S]*height:\s*1\.75rem\s*!important;/s,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -522,23 +291,15 @@ test("note code block frame is borderless and language picker is compact", () =>
|
||||
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;[^}]*padding:\s*0\s*!important;/s,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*border:\s*0\s*!important;[^}]*padding:\s*0\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s+\.cm-editor\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;/s,
|
||||
/\.netcatty-mdx-editor\s+\.cm-editor\s*\{[^}]*border:\s*0\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-content\s+pre\s*\{[^}]*border:\s*0;[^}]*background:\s*transparent;[^}]*padding:\s*0;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-note-code-copy\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;[^}]*box-shadow:\s*none\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*position:\s*absolute\s*!important;/s,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*position:\s*static\s*!important;[^}]*padding:\s*0\.125rem;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
@@ -546,11 +307,7 @@ test("note code block frame is borderless and language picker is compact", () =>
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor \[class\*="_codeMirrorToolbar_"\] \[class\*="_tooltipTrigger_"\]\s*\{[^}]*display:\s*inline-flex\s*!important;[^}]*align-items:\s*center\s*!important;[^}]*align-self:\s*center\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor \[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*0\s*!important;/s,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*fit-content\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
@@ -574,7 +331,7 @@ test("note code block frame is borderless and language picker is compact", () =>
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*gap:\s*0;[^}]*margin:\s*0\.25rem\s+0\s+0\.55rem;/s,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*gap:\s*0;[^}]*margin:\s*0\.3rem\s+0\s+0\.65rem;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
@@ -582,7 +339,7 @@ test("note code block frame is borderless and language picker is compact", () =>
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s+\.cm-gutters\s*\{[^}]*background:\s*transparent\s*!important;[^}]*padding:\s*0\s*!important;/s,
|
||||
/\.netcatty-mdx-editor\s+\.cm-gutters\s*\{[^}]*padding:\s*0\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
@@ -590,29 +347,6 @@ test("note code block frame is borderless and language picker is compact", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("note formulas render without framed surfaces", () => {
|
||||
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(styles, /data-language="math"/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;[^}]*border:\s*0;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-math-reading-mode\s*\{[^}]*background:\s*transparent\s*!important;[^}]*border:\s*none\s*!important;[^}]*padding:\s*0\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-math-reading-mode\s+\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;/s,
|
||||
);
|
||||
assert.match(styles, /\.netcatty-math-formula-preview\s*\{[^}]*justify-content:\s*safe center;[^}]*overflow-x:\s*auto;/s);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-math-reading-mode\s*>\s*\.netcatty-note-code-copy\s*\{[^}]*display:\s*none\s*!important;/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("getCodeMirrorBlockText reads rendered code block lines", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -630,62 +364,6 @@ test("annotateNoteCodeBlockCopyButtons adds a copy action to code blocks", () =>
|
||||
assert.match(source, /onCopy\(text\)/);
|
||||
});
|
||||
|
||||
test("annotateNoteCodeBlockCopyButtons treats a CSS-hidden toolbar as absent so preview shows the copy button", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /getComputedStyle\(toolbar\)\.display !== "none"/);
|
||||
assert.match(source, /toolbarVisible/);
|
||||
assert.match(source, /firstButton\.parentElement !== wrapper/);
|
||||
assert.match(source, /wrapper\.appendChild\(firstButton\)/);
|
||||
});
|
||||
|
||||
test("annotateNoteCodeBlockDeleteButtons swaps the delete icon only once", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
const dom = new JSDOM(`
|
||||
<div id="container">
|
||||
<div class="_codeMirrorToolbar_test">
|
||||
<button class="_toolbarCodeBlockLanguageSelectTrigger_test"></button>
|
||||
<button id="delete"></button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, "HTMLElement");
|
||||
Object.defineProperty(globalThis, "HTMLElement", {
|
||||
configurable: true,
|
||||
value: dom.window.HTMLElement,
|
||||
});
|
||||
|
||||
try {
|
||||
const container = dom.window.document.querySelector("#container") as HTMLElement;
|
||||
const deleteButton = dom.window.document.querySelector("#delete") as HTMLButtonElement;
|
||||
annotateNoteCodeBlockDeleteButtons(container);
|
||||
const installedIcon = deleteButton.firstElementChild;
|
||||
assert.equal(deleteButton.dataset.noteCodeDelete, "true");
|
||||
assert.equal(installedIcon?.nodeName.toLowerCase(), "svg");
|
||||
const observer = new dom.window.MutationObserver(() => {});
|
||||
observer.observe(deleteButton, { attributes: true, childList: true, subtree: true });
|
||||
|
||||
annotateNoteCodeBlockDeleteButtons(container);
|
||||
|
||||
assert.equal(observer.takeRecords().length, 0);
|
||||
assert.equal(deleteButton.firstElementChild, installedIcon);
|
||||
observer.disconnect();
|
||||
} finally {
|
||||
if (previousHTMLElement) {
|
||||
Object.defineProperty(globalThis, "HTMLElement", previousHTMLElement);
|
||||
} else {
|
||||
delete (globalThis as { HTMLElement?: unknown }).HTMLElement;
|
||||
}
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
assert.match(source, /export const annotateNoteCodeBlockDeleteButtons/);
|
||||
assert.match(source, /data-note-code-delete/);
|
||||
assert.match(source, /DELETE_ICON_SVG/);
|
||||
assert.match(source, /stroke="currentColor"/);
|
||||
assert.match(source, /stroke-width="2"/);
|
||||
});
|
||||
|
||||
test("note preview uses MDX readOnly with code-copy chrome", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
@@ -707,73 +385,18 @@ test("note preview uses MDX readOnly with code-copy chrome", () => {
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*justify-content:\s*flex-end;/s,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*position:\s*static\s*!important;[^}]*justify-content:\s*flex-end;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*font-size:\s*11px\s*!important;/s,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+\[class\*="_selectTrigger_"\]\s*\{[^}]*font-size:\s*11px\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\]\s+button[^{]*\{[^}]*height:\s*1\.4rem\s*!important;/s,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+button\s*\{[^}]*height:\s*1\.35rem\s*!important;/s,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.netcatty-mdx-editor\s+\[class\*="_codeMirrorToolbar_"\][^{]*svg\s*\{[^}]*width:\s*10px\s*!important;/s,
|
||||
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorToolbar_"\]\s+button\s+svg\s*\{[^}]*width:\s*14px\s*!important;/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("annotateMathFormulaBlocks handles empty math blocks and avoids reading toolbar text", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /export const annotateMathFormulaBlocks/);
|
||||
assert.match(source, /const text = getCodeMirrorBlockText\(wrapper\)\.trim\(\);/);
|
||||
assert.match(source, /if \(!formulaSource\) \{/);
|
||||
assert.match(source, /existingPreview\.remove\(\)/);
|
||||
});
|
||||
|
||||
test("NoteSourceEditor manages local draft state to prevent cursor jumping on debounced commits", () => {
|
||||
const source = readFileSync(new URL("./NoteSourceEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /const \[localValue, setLocalValue\] = useState\(value\);/);
|
||||
assert.match(source, /value=\{localValue\}/);
|
||||
assert.match(source, /onChange=\{handleChange\}/);
|
||||
assert.match(source, /prevNoteIdRef\.current/);
|
||||
assert.match(source, /prevValueRef\.current/);
|
||||
});
|
||||
|
||||
test("source mode compares raw markdown separately from display-normalized markdown", () => {
|
||||
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /const latestSourceMarkdownRef = useRef\(value\)/);
|
||||
assert.match(source, /markdown === latestSourceMarkdownRef\.current/);
|
||||
assert.match(source, /latestMarkdownRef\.current = normalizeNotePublicAssetPaths\(markdown\)/);
|
||||
assert.match(source, /setAcceptedSourceMarkdown\(markdown\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/<NoteSourceEditor[\s\S]*?value=\{noteId !== undefined && noteId !== noteIdRef\.current \? value : acceptedSourceMarkdown\}[\s\S]*?onChange=\{commitSourceMarkdown\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("raw source drafts are not overwritten by external values with equivalent display markdown", () => {
|
||||
const base = {
|
||||
latestMarkdown: "",
|
||||
syncedMarkdown: "",
|
||||
latestSourceMarkdown: "",
|
||||
syncedSourceMarkdown: "",
|
||||
};
|
||||
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
nextSourceMarkdown: "# Remote",
|
||||
}), false);
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
nextSourceMarkdown: "",
|
||||
}), true);
|
||||
assert.equal(shouldApplyExternalNoteMarkdown({
|
||||
...base,
|
||||
latestSourceMarkdown: "",
|
||||
nextSourceMarkdown: "",
|
||||
}), true);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,162 +0,0 @@
|
||||
import {
|
||||
Copy,
|
||||
FileText,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
type VaultNote,
|
||||
} from "../../domain/notes";
|
||||
import { copyToClipboard } from "../keychain/utils";
|
||||
import { toast } from "../ui/toast";
|
||||
|
||||
export interface NoteExportMenuProps {
|
||||
note: VaultNote | null;
|
||||
allNotes: VaultNote[];
|
||||
onExportNote: (note: VaultNote) => void;
|
||||
onExportAll: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NoteExportMenu: React.FC<NoteExportMenuProps> = ({
|
||||
note,
|
||||
allNotes,
|
||||
onExportNote,
|
||||
onExportAll,
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const closeAndRestoreFocus = useCallback(() => {
|
||||
setOpen(false);
|
||||
requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeAndRestoreFocus();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const items = Array.from(
|
||||
menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [],
|
||||
);
|
||||
if (!items.length) return;
|
||||
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
let next = current;
|
||||
if (e.key === "ArrowDown") next = (current + 1 + items.length) % items.length;
|
||||
else if (e.key === "ArrowUp") next = (current - 1 + items.length) % items.length;
|
||||
else if (e.key === "Home") next = 0;
|
||||
else if (e.key === "End") next = items.length - 1;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
items[next]?.focus();
|
||||
};
|
||||
window.addEventListener("mousedown", handleClickOutside);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
requestAnimationFrame(() => {
|
||||
menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleClickOutside);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [closeAndRestoreFocus, open]);
|
||||
|
||||
const handleExportSingleMarkdown = () => {
|
||||
if (!note) return;
|
||||
onExportNote(note);
|
||||
closeAndRestoreFocus();
|
||||
};
|
||||
|
||||
const handleCopyMarkdown = async () => {
|
||||
if (!note) return;
|
||||
const ok = await copyToClipboard(note.content);
|
||||
if (ok) {
|
||||
toast.success(t("common.copied") || "已复制到剪贴板");
|
||||
}
|
||||
closeAndRestoreFocus();
|
||||
};
|
||||
|
||||
const handleExportAllZip = () => {
|
||||
if (!allNotes.length) return;
|
||||
onExportAll();
|
||||
closeAndRestoreFocus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block ${className}`} ref={menuRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("notes.export.share")}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div role="menu" className="absolute right-0 top-full mt-1.5 w-56 bg-popover border border-border rounded-lg shadow-lg py-1.5 z-50 text-sm animate-in fade-in-50 zoom-in-95">
|
||||
{note && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{t("notes.export.currentNote")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
|
||||
onClick={handleExportSingleMarkdown}
|
||||
>
|
||||
<FileText size={14} className="text-primary" />
|
||||
<span>{t("notes.export.exportMarkdown")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
|
||||
onClick={handleCopyMarkdown}
|
||||
>
|
||||
<Copy size={14} className="text-muted-foreground" />
|
||||
<span>{t("notes.export.copyMarkdown")}</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{t("notes.export.allNotes")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 hover:bg-muted text-foreground transition-colors text-left"
|
||||
onClick={handleExportAllZip}
|
||||
disabled={!allNotes.length}
|
||||
>
|
||||
<FileText size={14} className="text-emerald-500" />
|
||||
<span>{t("notes.export.exportAllZip")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import { NoteOutline } from "./NoteOutline.tsx";
|
||||
|
||||
test("note outline renders a borderless hierarchy without heading-level labels", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-CN">
|
||||
<NoteOutline
|
||||
content={"## 第一节\n\n### 子章节\n\n## 第二节"}
|
||||
onClose={() => undefined}
|
||||
onSelectHeading={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /data-note-outline="true"/);
|
||||
assert.equal(markup.match(/data-note-outline-item=/g)?.length, 3);
|
||||
assert.match(markup, /padding-left:10px/);
|
||||
assert.match(markup, /padding-left:22px/);
|
||||
assert.doesNotMatch(markup, />H[1-6]</);
|
||||
assert.doesNotMatch(markup, /border-l|border-b|uppercase|tracking-wider/);
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { ListTree, X } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { extractNoteHeadings, type NoteHeadingItem } from "../../domain/notes";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
|
||||
export interface NoteOutlineProps {
|
||||
content: string;
|
||||
onSelectHeading?: (heading: NoteHeadingItem, index: number) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NoteOutline: React.FC<NoteOutlineProps> = ({
|
||||
content,
|
||||
onSelectHeading,
|
||||
onClose,
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const headings = useMemo(() => extractNoteHeadings(content), [content]);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
const minimumHeadingLevel = useMemo(
|
||||
() => headings.length > 0
|
||||
? Math.min(...headings.map((heading) => heading.level))
|
||||
: 1,
|
||||
[headings],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveHeadingId(null);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t("notes.outline.title")}
|
||||
data-note-outline="true"
|
||||
className={`flex h-full flex-col bg-background select-none ${className}`}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between px-3.5 pb-2 pt-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground/90">
|
||||
<ListTree size={14} className="shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{t("notes.outline.title")}</span>
|
||||
<span className="shrink-0 text-[11px] font-normal tabular-nums text-muted-foreground/60">
|
||||
{headings.length}
|
||||
</span>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60"
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
||||
{headings.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
|
||||
<p>{t("notes.outline.empty")}</p>
|
||||
<p className="mt-1 text-[11px] opacity-70">
|
||||
{t("notes.outline.emptyHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
headings.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-note-outline-item={item.id}
|
||||
data-heading-level={item.level}
|
||||
className={`flex w-full items-center rounded-md py-1.5 pr-2 text-left text-xs leading-5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60 ${
|
||||
activeHeadingId === item.id
|
||||
? "bg-secondary/60 font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-secondary/40 hover:text-foreground"
|
||||
}`}
|
||||
style={{ paddingLeft: `${10 + Math.min(item.level - minimumHeadingLevel, 4) * 12}px` }}
|
||||
onClick={() => {
|
||||
setActiveHeadingId(item.id);
|
||||
onSelectHeading?.(item, index);
|
||||
}}
|
||||
title={item.text}
|
||||
>
|
||||
<span className="truncate">{item.text}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,265 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import {
|
||||
NoteSourceEditor,
|
||||
type NoteSourceEditorHandle,
|
||||
} from "./NoteSourceEditor.tsx";
|
||||
|
||||
test("NoteSourceEditor toolbar undo restores coalesced ordinary typing", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
actEnvironment.requestAnimationFrame = (callback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const editorRef = React.createRef<NoteSourceEditorHandle>();
|
||||
const changes: string[] = [];
|
||||
const textareaNode = {
|
||||
selectionStart: 1,
|
||||
selectionEnd: 1,
|
||||
focus: () => undefined,
|
||||
setSelectionRange(start: number, end: number) {
|
||||
this.selectionStart = start;
|
||||
this.selectionEnd = end;
|
||||
},
|
||||
scrollTop: 0,
|
||||
scrollTo: () => undefined,
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<NoteSourceEditor
|
||||
ref={editorRef}
|
||||
noteId="note-1"
|
||||
value="a"
|
||||
onChange={(nextValue) => changes.push(nextValue)}
|
||||
/>,
|
||||
{
|
||||
createNodeMock: (element) => element.type === "textarea"
|
||||
? textareaNode
|
||||
: { scrollTop: 0 },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = renderer!.root.findByType("textarea");
|
||||
await act(async () => {
|
||||
textarea.props.onChange({
|
||||
target: { value: "ab" },
|
||||
nativeEvent: { inputType: "insertText" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.props.onChange({
|
||||
target: { value: "abc" },
|
||||
nativeEvent: { inputType: "insertText" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
editorRef.current?.insertAction("undo");
|
||||
});
|
||||
|
||||
let prevented = false;
|
||||
await act(async () => {
|
||||
renderer!.root.findByType("textarea").props.onKeyDown({
|
||||
key: "z",
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
preventDefault: () => { prevented = true; },
|
||||
});
|
||||
});
|
||||
const composedTextarea = renderer!.root.findByType("textarea");
|
||||
await act(async () => {
|
||||
composedTextarea.props.onCompositionStart();
|
||||
composedTextarea.props.onChange({
|
||||
target: { value: "abc你", selectionStart: 4 },
|
||||
nativeEvent: { inputType: "insertCompositionText" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType("textarea").props.onCompositionEnd();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType("textarea").props.onCompositionStart();
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: "abc你好", selectionStart: 5 },
|
||||
nativeEvent: { inputType: "insertCompositionText" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType("textarea").props.onCompositionEnd();
|
||||
});
|
||||
await act(async () => editorRef.current?.insertAction("undo"));
|
||||
await act(async () => editorRef.current?.insertAction("undo"));
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(changes, ["ab", "abc", "a", "abc", "abc你", "abc你好", "abc你", "abc"]);
|
||||
assert.equal(renderer!.root.findByType("textarea").props.value, "abc");
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
actEnvironment.requestAnimationFrame = previousAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
test("NoteSourceEditor keeps formatting and later typing as separate undo steps", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
actEnvironment.requestAnimationFrame = (callback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const editorRef = React.createRef<NoteSourceEditorHandle>();
|
||||
const changes: string[] = [];
|
||||
const textareaNode = {
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
focus: () => undefined,
|
||||
setSelectionRange(start: number, end: number) {
|
||||
this.selectionStart = start;
|
||||
this.selectionEnd = end;
|
||||
},
|
||||
scrollTop: 0,
|
||||
scrollTo: () => undefined,
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<NoteSourceEditor
|
||||
ref={editorRef}
|
||||
noteId="note-1"
|
||||
value=""
|
||||
onChange={(nextValue) => changes.push(nextValue)}
|
||||
/>,
|
||||
{
|
||||
createNodeMock: (element) => element.type === "textarea"
|
||||
? textareaNode
|
||||
: { scrollTop: 0 },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
editorRef.current?.insertAction("bold");
|
||||
});
|
||||
const formatted = "**bold text**";
|
||||
const edited = "**bold text!**";
|
||||
const textarea = renderer!.root.findByType("textarea");
|
||||
await act(async () => {
|
||||
textarea.props.onChange({
|
||||
target: { value: edited },
|
||||
nativeEvent: { inputType: "insertText" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
editorRef.current?.insertAction("undo");
|
||||
});
|
||||
await act(async () => {
|
||||
editorRef.current?.insertAction("undo");
|
||||
});
|
||||
|
||||
assert.deepEqual(changes, [formatted, edited, formatted, ""]);
|
||||
assert.equal(renderer!.root.findByType("textarea").props.value, "");
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
actEnvironment.requestAnimationFrame = previousAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
test("NoteSourceEditor undo and redo restore toolbar formatting selections", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousAnimationFrame = actEnvironment.requestAnimationFrame;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
actEnvironment.requestAnimationFrame = (callback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const editorRef = React.createRef<NoteSourceEditorHandle>();
|
||||
const textareaNode = {
|
||||
selectionStart: 0,
|
||||
selectionEnd: 5,
|
||||
focus: () => undefined,
|
||||
setSelectionRange(start: number, end: number) {
|
||||
this.selectionStart = start;
|
||||
this.selectionEnd = end;
|
||||
},
|
||||
scrollTop: 0,
|
||||
scrollTo: () => undefined,
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<NoteSourceEditor
|
||||
ref={editorRef}
|
||||
noteId="note-1"
|
||||
value="hello"
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
{
|
||||
createNodeMock: (element) => element.type === "textarea"
|
||||
? textareaNode
|
||||
: { scrollTop: 0 },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => editorRef.current?.insertAction("bold"));
|
||||
assert.deepEqual(
|
||||
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
|
||||
{ start: 2, end: 7 },
|
||||
);
|
||||
|
||||
await act(async () => editorRef.current?.insertAction("undo"));
|
||||
assert.equal(renderer!.root.findByType("textarea").props.value, "hello");
|
||||
assert.deepEqual(
|
||||
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
|
||||
{ start: 0, end: 5 },
|
||||
);
|
||||
|
||||
await act(async () => editorRef.current?.insertAction("redo"));
|
||||
assert.equal(renderer!.root.findByType("textarea").props.value, "**hello**");
|
||||
assert.deepEqual(
|
||||
{ start: textareaNode.selectionStart, end: textareaNode.selectionEnd },
|
||||
{ start: 2, end: 7 },
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
actEnvironment.requestAnimationFrame = previousAnimationFrame;
|
||||
}
|
||||
});
|
||||
@@ -1,374 +0,0 @@
|
||||
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
import { type MarkdownActionType, wrapMarkdownSyntax } from "../../domain/notes";
|
||||
|
||||
const SOURCE_EDIT_UNDO_COALESCE_MS = 750;
|
||||
|
||||
interface SourceHistorySnapshot {
|
||||
value: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
export const shouldCoalesceSourceUndoStep = (
|
||||
previous: { inputType: string; at: number; caret: number } | null,
|
||||
inputType: string,
|
||||
now: number,
|
||||
edit: { start: number; removedLength: number; insertedLength: number },
|
||||
): boolean => {
|
||||
if (!previous || previous.inputType !== inputType || now - previous.at > SOURCE_EDIT_UNDO_COALESCE_MS) {
|
||||
return false;
|
||||
}
|
||||
if (inputType === "insertText") {
|
||||
return edit.removedLength === 0 && edit.start === previous.caret;
|
||||
}
|
||||
if (inputType === "deleteContentBackward") {
|
||||
return edit.insertedLength === 0 && edit.start + edit.removedLength === previous.caret;
|
||||
}
|
||||
if (inputType === "deleteContentForward") {
|
||||
return edit.insertedLength === 0 && edit.start === previous.caret;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getSourceEditDelta = (
|
||||
previousValue: string,
|
||||
nextValue: string,
|
||||
): { start: number; removedLength: number; insertedLength: number } => {
|
||||
let start = 0;
|
||||
const sharedLength = Math.min(previousValue.length, nextValue.length);
|
||||
while (start < sharedLength && previousValue[start] === nextValue[start]) start += 1;
|
||||
|
||||
let previousEnd = previousValue.length;
|
||||
let nextEnd = nextValue.length;
|
||||
while (
|
||||
previousEnd > start
|
||||
&& nextEnd > start
|
||||
&& previousValue[previousEnd - 1] === nextValue[nextEnd - 1]
|
||||
) {
|
||||
previousEnd -= 1;
|
||||
nextEnd -= 1;
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
removedLength: previousEnd - start,
|
||||
insertedLength: nextEnd - start,
|
||||
};
|
||||
};
|
||||
|
||||
export interface NoteSourceEditorHandle {
|
||||
insertAction: (action: MarkdownActionType) => void;
|
||||
focus: () => void;
|
||||
scrollToLine: (line: number) => boolean;
|
||||
}
|
||||
|
||||
export interface NoteSourceEditorProps {
|
||||
noteId?: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
noteFontFamily?: string;
|
||||
noteFontSize?: number;
|
||||
}
|
||||
|
||||
export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSourceEditorProps>(
|
||||
({ noteId, value, placeholder = "", onChange, className = "", noteFontFamily, noteFontSize }, ref) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const lineNumbersRef = useRef<HTMLDivElement>(null);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const prevNoteIdRef = useRef(noteId);
|
||||
const prevValueRef = useRef(value);
|
||||
|
||||
// Undo/redo history for the source textarea (native textarea undo is
|
||||
// unreliable once the value is controlled by React).
|
||||
const undoStackRef = useRef<SourceHistorySnapshot[]>([]);
|
||||
const redoStackRef = useRef<SourceHistorySnapshot[]>([]);
|
||||
const lastUserEditRef = useRef<{ inputType: string; at: number; caret: number } | null>(null);
|
||||
const compositionBaselineRef = useRef<SourceHistorySnapshot | null>(null);
|
||||
const skipNextCompositionCommitRef = useRef(false);
|
||||
|
||||
const resetUserEditCoalescing = () => {
|
||||
lastUserEditRef.current = null;
|
||||
};
|
||||
|
||||
const createSnapshot = (
|
||||
snapshotValue = localValue,
|
||||
selectionStart = textareaRef.current?.selectionStart ?? snapshotValue.length,
|
||||
selectionEnd = textareaRef.current?.selectionEnd ?? selectionStart,
|
||||
): SourceHistorySnapshot => ({
|
||||
value: snapshotValue,
|
||||
selectionStart: Math.min(selectionStart, snapshotValue.length),
|
||||
selectionEnd: Math.min(selectionEnd, snapshotValue.length),
|
||||
});
|
||||
|
||||
// Adopt the external value only for genuine external changes:
|
||||
// - noteId switch → always adopt (new note).
|
||||
// - prop value changed AND differs from localValue → external edit.
|
||||
// The parent debounces our own edits and echoes them back with
|
||||
// value === localValue; those echoes must NOT reset the textarea, or the
|
||||
// keystroke would be reverted and the caret would jump to the end.
|
||||
useEffect(() => {
|
||||
if (noteId !== prevNoteIdRef.current) {
|
||||
prevNoteIdRef.current = noteId;
|
||||
prevValueRef.current = value;
|
||||
setLocalValue(value);
|
||||
undoStackRef.current = [];
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
resetUserEditCoalescing();
|
||||
return;
|
||||
}
|
||||
if (value !== prevValueRef.current) {
|
||||
prevValueRef.current = value;
|
||||
if (value !== localValue) {
|
||||
setLocalValue(value);
|
||||
undoStackRef.current = [];
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
resetUserEditCoalescing();
|
||||
}
|
||||
}
|
||||
}, [noteId, value, localValue]);
|
||||
|
||||
const lineCount = (localValue.match(/\n/g)?.length || 0) + 1;
|
||||
const lineNumbers = useMemo(
|
||||
() => Array.from({ length: lineCount }, (_, i) => i + 1).join("\n"),
|
||||
[lineCount],
|
||||
);
|
||||
const gutterWidth = Math.max(48, String(lineCount).length * 9 + 24);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (textareaRef.current && lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const nextValue = e.target.value;
|
||||
if (nextValue === localValue) return;
|
||||
const nativeInputType = (e.nativeEvent as InputEvent | undefined)?.inputType;
|
||||
const inputType = typeof nativeInputType === "string" && nativeInputType
|
||||
? nativeInputType
|
||||
: "input";
|
||||
const isCompositionInput = inputType === "insertCompositionText"
|
||||
|| inputType === "insertFromComposition";
|
||||
if (isCompositionInput) {
|
||||
if (inputType === "insertFromComposition" && skipNextCompositionCommitRef.current) {
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
if (compositionBaselineRef.current === null) {
|
||||
const edit = getSourceEditDelta(localValue, nextValue);
|
||||
const baseline = createSnapshot(
|
||||
localValue,
|
||||
edit.start,
|
||||
edit.start + edit.removedLength,
|
||||
);
|
||||
compositionBaselineRef.current = baseline;
|
||||
undoStackRef.current.push(baseline);
|
||||
redoStackRef.current = [];
|
||||
}
|
||||
if (inputType === "insertFromComposition") compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
compositionBaselineRef.current = null;
|
||||
const now = Date.now();
|
||||
const edit = getSourceEditDelta(localValue, nextValue);
|
||||
if (!shouldCoalesceSourceUndoStep(lastUserEditRef.current, inputType, now, edit)) {
|
||||
const previousSelectionStart = inputType === "deleteContentBackward"
|
||||
? edit.start + edit.removedLength
|
||||
: edit.start;
|
||||
undoStackRef.current.push(createSnapshot(
|
||||
localValue,
|
||||
previousSelectionStart,
|
||||
edit.start + edit.removedLength,
|
||||
));
|
||||
}
|
||||
const caret = typeof e.target.selectionStart === "number"
|
||||
? e.target.selectionStart
|
||||
: edit.start + edit.insertedLength;
|
||||
lastUserEditRef.current = { inputType, at: now, caret };
|
||||
redoStackRef.current = [];
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
||||
const applyHistoryAction = (action: "undo" | "redo"): boolean => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return false;
|
||||
const stack = action === "undo" ? undoStackRef.current : redoStackRef.current;
|
||||
const target = stack.pop();
|
||||
if (target === undefined) return false;
|
||||
resetUserEditCoalescing();
|
||||
compositionBaselineRef.current = null;
|
||||
const current = createSnapshot();
|
||||
if (action === "undo") {
|
||||
redoStackRef.current.push(current);
|
||||
} else {
|
||||
undoStackRef.current.push(current);
|
||||
}
|
||||
setLocalValue(target.value);
|
||||
onChange(target.value);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(target.selectionStart, target.selectionEnd);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const commandModifier = e.metaKey || e.ctrlKey;
|
||||
const historyAction = commandModifier && !e.altKey
|
||||
? key === "z"
|
||||
? (e.shiftKey ? "redo" : "undo")
|
||||
: key === "y" && e.ctrlKey
|
||||
? "redo"
|
||||
: null
|
||||
: null;
|
||||
if (historyAction) {
|
||||
e.preventDefault();
|
||||
applyHistoryAction(historyAction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(e.key)) {
|
||||
resetUserEditCoalescing();
|
||||
}
|
||||
|
||||
// Handle Tab insertion
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const nextValue = `${localValue.substring(0, start)} ${localValue.substring(end)}`;
|
||||
undoStackRef.current.push(createSnapshot(localValue, start, end));
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = start + 2;
|
||||
textarea.selectionEnd = start + 2;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
insertAction: (action: MarkdownActionType) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
if (action === "undo" || action === "redo") {
|
||||
applyHistoryAction(action);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const result = wrapMarkdownSyntax(localValue, start, end, action);
|
||||
if (result.text !== localValue) {
|
||||
undoStackRef.current.push(createSnapshot(localValue, start, end));
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
}
|
||||
setLocalValue(result.text);
|
||||
onChange(result.text);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(result.selectionStart, result.selectionEnd);
|
||||
});
|
||||
},
|
||||
focus: () => {
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
scrollToLine: (line: number) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return false;
|
||||
const top = Math.max(0, (Math.max(1, line) - 1) * 24 - 12);
|
||||
if (typeof textarea.scrollTo === "function") {
|
||||
textarea.scrollTo({ top, behavior: "smooth" });
|
||||
} else {
|
||||
textarea.scrollTop = top;
|
||||
}
|
||||
if (lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = top;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-full w-full bg-background font-mono text-sm select-text overflow-hidden ${className}`}
|
||||
>
|
||||
{/* Line numbers gutter */}
|
||||
<div
|
||||
ref={lineNumbersRef}
|
||||
style={{ width: `${gutterWidth}px` }}
|
||||
className="shrink-0 py-3 select-none text-right pr-3 text-muted-foreground/40 border-r border-border/50 overflow-hidden font-mono text-sm leading-6"
|
||||
onWheel={(e) => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.scrollTop += e.deltaY;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<pre className="m-0 whitespace-pre font-inherit leading-6">{lineNumbers}</pre>
|
||||
</div>
|
||||
|
||||
{/* Source Textarea */}
|
||||
<div className="relative flex-1 h-full min-w-0">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={resetUserEditCoalescing}
|
||||
onCompositionStart={() => {
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
if (compositionBaselineRef.current === null) {
|
||||
const baseline = createSnapshot();
|
||||
compositionBaselineRef.current = baseline;
|
||||
undoStackRef.current.push(baseline);
|
||||
redoStackRef.current = [];
|
||||
}
|
||||
resetUserEditCoalescing();
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
const baseline = compositionBaselineRef.current;
|
||||
if (baseline !== null && baseline.value === localValue) {
|
||||
undoStackRef.current.pop();
|
||||
}
|
||||
skipNextCompositionCommitRef.current = baseline !== null && baseline.value !== localValue;
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
fontFamily: noteFontFamily || undefined,
|
||||
fontSize: noteFontSize ? `${noteFontSize}px` : undefined,
|
||||
}}
|
||||
className="w-full h-full py-3 px-4 bg-transparent text-foreground resize-none outline-none font-mono text-sm leading-6 whitespace-pre overflow-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
NoteSourceEditor.displayName = "NoteSourceEditor";
|
||||
@@ -1,523 +0,0 @@
|
||||
import {
|
||||
Bold,
|
||||
Check,
|
||||
CheckSquare,
|
||||
Code,
|
||||
Eye,
|
||||
FileCode,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading,
|
||||
Image as ImageIcon,
|
||||
Italic,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
Minus,
|
||||
PencilLine,
|
||||
Quote,
|
||||
Redo2,
|
||||
Search,
|
||||
Sigma,
|
||||
SquareCode,
|
||||
Strikethrough,
|
||||
Table as TableIcon,
|
||||
Type,
|
||||
Underline,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { type MarkdownActionType } from "../../domain/notes";
|
||||
import { useAvailableFonts } from "../../application/state/fontStore";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import type { ActiveTextFormats, NoteEditorMode } from "./noteEditorTypes";
|
||||
import { EMPTY_ACTIVE_FORMATS } from "./noteEditorTypes";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "../ui/select";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface NoteToolbarProps {
|
||||
editorMode: NoteEditorMode;
|
||||
onAction?: (action: MarkdownActionType) => void;
|
||||
onOpenHostPicker?: () => void;
|
||||
className?: string;
|
||||
noteFontFamily?: string;
|
||||
onChangeNoteFontFamily?: (font: string) => void;
|
||||
noteFontSize?: number;
|
||||
onChangeNoteFontSize?: (size: number) => void;
|
||||
noteCodeFontSize?: number;
|
||||
onChangeNoteCodeFontSize?: (size: number) => void;
|
||||
/** Active text-format toggles at the current selection (button highlight). */
|
||||
activeFormats?: ActiveTextFormats;
|
||||
}
|
||||
|
||||
export interface NoteModeDropdownProps {
|
||||
editorMode: NoteEditorMode;
|
||||
onChangeMode: (mode: NoteEditorMode) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FONT_SIZES = [12, 13, 14, 15, 16, 18, 20];
|
||||
const CODE_FONT_SIZES = [11, 12, 13, 14, 15, 16, 18];
|
||||
|
||||
export const NoteModeDropdown: React.FC<NoteModeDropdownProps> = ({
|
||||
editorMode,
|
||||
onChangeMode,
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const normalizedMode: "edit" | "source" | "preview" =
|
||||
editorMode === "live" ? "edit" : editorMode;
|
||||
const options = [
|
||||
{
|
||||
mode: "edit" as const,
|
||||
label: t("notes.toolbar.modeLive"),
|
||||
title: t("notes.toolbar.modeLiveTitle"),
|
||||
icon: PencilLine,
|
||||
},
|
||||
{
|
||||
mode: "source" as const,
|
||||
label: t("notes.toolbar.modeSource"),
|
||||
title: t("notes.toolbar.modeSourceTitle"),
|
||||
icon: SquareCode,
|
||||
},
|
||||
{
|
||||
mode: "preview" as const,
|
||||
label: t("notes.toolbar.modePreview"),
|
||||
title: t("notes.toolbar.modePreviewTitle"),
|
||||
icon: Eye,
|
||||
},
|
||||
];
|
||||
const activeOption = options.find((option) => option.mode === normalizedMode) ?? options[0];
|
||||
const ActiveIcon = activeOption.icon;
|
||||
|
||||
return (
|
||||
<Select value={normalizedMode} onValueChange={(mode) => onChangeMode(mode as NoteEditorMode)}>
|
||||
<SelectTrigger
|
||||
data-note-mode-dropdown-trigger
|
||||
aria-label={activeOption.title}
|
||||
className={cn(
|
||||
"app-no-drag h-8 w-auto shrink-0 gap-1.5 border-0 bg-transparent px-2 text-xs text-muted-foreground shadow-none hover:bg-secondary/70 hover:text-foreground focus:ring-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ActiveIcon size={15} />
|
||||
<span>{activeOption.label}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" className="w-max min-w-[10rem]">
|
||||
{options.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<SelectItem
|
||||
key={option.mode}
|
||||
value={option.mode}
|
||||
data-note-mode-option={option.mode}
|
||||
className="h-9 whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap"><Icon size={14} />{option.label}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoteToolbar: React.FC<NoteToolbarProps> = ({
|
||||
editorMode,
|
||||
onAction,
|
||||
className = "",
|
||||
noteFontFamily = "",
|
||||
onChangeNoteFontFamily,
|
||||
noteFontSize = 14,
|
||||
onChangeNoteFontSize,
|
||||
noteCodeFontSize = 13,
|
||||
onChangeNoteCodeFontSize,
|
||||
activeFormats = EMPTY_ACTIVE_FORMATS,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [fontSearch, setFontSearch] = useState("");
|
||||
|
||||
// The font tool only controls code block / inline code fonts, so it lists
|
||||
// system monospace fonts (fontStore) rather than the UI font set.
|
||||
const availableSystemFonts = useAvailableFonts();
|
||||
|
||||
const systemFontList = useMemo(() => {
|
||||
const defaultOption = { label: t("notes.toolbar.defaultFont"), value: "" };
|
||||
const list = availableSystemFonts.map((f) => ({
|
||||
label: f.name,
|
||||
value: f.family,
|
||||
}));
|
||||
return [defaultOption, ...list];
|
||||
}, [availableSystemFonts, t]);
|
||||
|
||||
const filteredFonts = useMemo(() => {
|
||||
if (!fontSearch.trim()) return systemFontList;
|
||||
const query = fontSearch.trim().toLowerCase();
|
||||
return systemFontList.filter(
|
||||
(f) => f.label.toLowerCase().includes(query) || f.value.toLowerCase().includes(query),
|
||||
);
|
||||
}, [fontSearch, systemFontList]);
|
||||
|
||||
const isEditing = editorMode === "edit" || editorMode === "live" || editorMode === "source";
|
||||
|
||||
if (!isEditing) return null;
|
||||
|
||||
// Highlight style for toggles that are active at the current selection.
|
||||
const formatButtonClass = (active: boolean) =>
|
||||
cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
active
|
||||
? "bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 border-b border-border/70 bg-card/40 text-xs select-none min-w-0 ${className}`}
|
||||
>
|
||||
{/* Formatting Tools (Available in Live Preview & Source Mode) */}
|
||||
<div className="flex flex-1 items-center gap-0.5 min-w-0 overflow-x-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/70 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
|
||||
{/* Undo / Redo */}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("undo")}
|
||||
title={t("notes.toolbar.undo")}
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("redo")}
|
||||
title={t("notes.toolbar.redo")}
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-border mx-1 shrink-0" />
|
||||
|
||||
{/* Code Font & Typography Settings Dropdown */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
title={t("notes.toolbar.typography")}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Type size={14} />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="start" className="w-64 p-2.5 space-y-2.5 z-50 text-xs shadow-lg">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[11px] font-medium text-muted-foreground">
|
||||
<span>{t("notes.toolbar.customCodeFont")}</span>
|
||||
<span className="text-[10px] opacity-70">
|
||||
{t("notes.toolbar.fontsAvailable", { count: systemFontList.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative flex items-center">
|
||||
<Search size={12} className="absolute left-2 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("notes.toolbar.searchFont")}
|
||||
value={fontSearch}
|
||||
onChange={(e) => setFontSearch(e.target.value)}
|
||||
className="w-full pl-6 pr-2 py-1 rounded border border-border bg-background text-[11px] text-foreground outline-none focus:border-primary placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Font List */}
|
||||
<div className="space-y-0.5 max-h-52 overflow-y-auto pr-1">
|
||||
{filteredFonts.length === 0 ? (
|
||||
<div className="py-2 text-center text-muted-foreground text-[11px]">
|
||||
{t("notes.toolbar.noFontsFound")}
|
||||
</div>
|
||||
) : (
|
||||
filteredFonts.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
style={{ fontFamily: f.value || undefined }}
|
||||
className={cn(
|
||||
"w-full px-2 py-1 rounded text-left text-xs transition-colors flex items-center justify-between gap-1.5",
|
||||
(noteFontFamily || "") === f.value
|
||||
? "bg-primary text-primary-foreground font-medium"
|
||||
: "hover:bg-secondary text-foreground",
|
||||
)}
|
||||
onClick={() => onChangeNoteFontFamily?.(f.value)}
|
||||
>
|
||||
<span className="truncate">{f.label}</span>
|
||||
{(noteFontFamily || "") === f.value && <Check size={12} className="shrink-0" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-2">
|
||||
<div className="text-[11px] font-medium text-muted-foreground mb-1">{t("notes.toolbar.bodyFontSize")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{FONT_SIZES.map((sz) => (
|
||||
<button
|
||||
key={sz}
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs border transition-colors",
|
||||
(noteFontSize || 14) === sz
|
||||
? "bg-primary text-primary-foreground border-primary font-medium"
|
||||
: "border-border hover:bg-secondary text-foreground",
|
||||
)}
|
||||
onClick={() => onChangeNoteFontSize?.(sz)}
|
||||
>
|
||||
{sz}px
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-2">
|
||||
<div className="text-[11px] font-medium text-muted-foreground mb-1">{t("notes.toolbar.codeFontSize")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{CODE_FONT_SIZES.map((sz) => (
|
||||
<button
|
||||
key={sz}
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs border transition-colors font-mono",
|
||||
(noteCodeFontSize || 13) === sz
|
||||
? "bg-primary text-primary-foreground border-primary font-medium"
|
||||
: "border-border hover:bg-secondary text-foreground",
|
||||
)}
|
||||
onClick={() => onChangeNoteCodeFontSize?.(sz)}
|
||||
>
|
||||
{sz}px
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
{/* Heading Dropdown (Using Portal Dropdown to avoid clipping) */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title={t("notes.toolbar.headingLevel")}
|
||||
>
|
||||
<Heading size={14} />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="start" className="w-32 py-1 z-50 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("h1")}
|
||||
>
|
||||
<Heading1 size={14} className="text-primary" />
|
||||
<span>{t("notes.toolbar.h1")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("h2")}
|
||||
>
|
||||
<Heading2 size={14} className="text-primary" />
|
||||
<span>{t("notes.toolbar.h2")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("h3")}
|
||||
>
|
||||
<Heading3 size={14} className="text-primary" />
|
||||
<span>{t("notes.toolbar.h3")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-secondary text-foreground transition-colors text-left"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("h4")}
|
||||
>
|
||||
<Heading4 size={14} className="text-primary" />
|
||||
<span>{t("notes.toolbar.h4")}</span>
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
{/* Inline Styles */}
|
||||
<button
|
||||
type="button"
|
||||
className={formatButtonClass(activeFormats.bold)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("bold")}
|
||||
title={t("notes.toolbar.bold")}
|
||||
>
|
||||
<Bold size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={formatButtonClass(activeFormats.italic)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("italic")}
|
||||
title={t("notes.toolbar.italic")}
|
||||
>
|
||||
<Italic size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={formatButtonClass(activeFormats.strikethrough)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("strikethrough")}
|
||||
title={t("notes.toolbar.strikethrough")}
|
||||
>
|
||||
<Strikethrough size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={formatButtonClass(activeFormats.underline)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("underline")}
|
||||
title={t("notes.toolbar.underline")}
|
||||
>
|
||||
<Underline size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={formatButtonClass(activeFormats.code)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("code")}
|
||||
title={t("notes.toolbar.inlineCode")}
|
||||
>
|
||||
<Code size={14} />
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-border mx-1 shrink-0" />
|
||||
|
||||
{/* Lists */}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("bullet")}
|
||||
title={t("notes.toolbar.bulletList")}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("number")}
|
||||
title={t("notes.toolbar.orderedList")}
|
||||
>
|
||||
<ListOrdered size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("task")}
|
||||
title={t("notes.toolbar.taskList")}
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-border mx-1 shrink-0" />
|
||||
|
||||
{/* Blocks */}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("quote")}
|
||||
title={t("notes.toolbar.quote")}
|
||||
>
|
||||
<Quote size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("codeblock")}
|
||||
title={t("notes.toolbar.codeBlock")}
|
||||
>
|
||||
<FileCode size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("math")}
|
||||
title={t("notes.toolbar.mathFormula")}
|
||||
>
|
||||
<Sigma size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("table")}
|
||||
title={t("notes.toolbar.table")}
|
||||
>
|
||||
<TableIcon size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("divider")}
|
||||
title={t("notes.toolbar.divider")}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("link")}
|
||||
title={t("notes.toolbar.link")}
|
||||
>
|
||||
<LinkIcon size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onAction?.("image")}
|
||||
title={t("notes.toolbar.image")}
|
||||
>
|
||||
<ImageIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -180,11 +180,7 @@ test("host-link annotation does not re-run on every markdown value keystroke", (
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/shouldApplyExternalNoteMarkdown/,
|
||||
);
|
||||
assert.match(
|
||||
editorSource,
|
||||
/syncedSourceMarkdownRef/,
|
||||
/latestMarkdownRef\.current !== syncedPropValueRef\.current/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import { readStoredStringValue } from "../../application/state/useStoredString.ts";
|
||||
import { STORAGE_KEY_VAULT_NOTES_EDITOR_MODE } from "../../infrastructure/config/storageKeys.ts";
|
||||
import type { VaultNote } from "../../types.ts";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
import {
|
||||
@@ -20,8 +17,6 @@ import {
|
||||
getValidatedNoteSelectionState,
|
||||
getSelectedVaultNote,
|
||||
isNoteFolderTreeSelected,
|
||||
isNoteEditorMode,
|
||||
normalizeNoteEditorMode,
|
||||
NotesManager,
|
||||
} from "./NotesManager.tsx";
|
||||
|
||||
@@ -87,45 +82,6 @@ test("NotesManager balances folder and note tree icon sizes", () => {
|
||||
|
||||
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-folder/);
|
||||
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
|
||||
assert.match(markup, /<div class="flex shrink-0 items-center[^"]*mr-1">/);
|
||||
assert.doesNotMatch(markup, /lucide lucide-file-text[^"]*mr-2/);
|
||||
});
|
||||
|
||||
test("NotesManager gives folder and tag metadata pills the same compact style", () => {
|
||||
const markup = renderNotes([note({ tags: ["inspection"] })]);
|
||||
const document = new JSDOM(markup).window.document;
|
||||
const folderPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="folder"]');
|
||||
const tagPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="tag"]');
|
||||
const addTagPill = document.querySelector<HTMLElement>('[data-note-metadata-pill="add-tag"]');
|
||||
|
||||
assert.ok(folderPill);
|
||||
assert.ok(tagPill);
|
||||
assert.ok(addTagPill);
|
||||
for (const className of [
|
||||
"inline-flex",
|
||||
"h-5",
|
||||
"items-center",
|
||||
"gap-1",
|
||||
"rounded-md",
|
||||
"bg-muted/70",
|
||||
"px-2",
|
||||
"text-[11px]",
|
||||
"font-medium",
|
||||
"leading-none",
|
||||
]) {
|
||||
assert.ok(folderPill.classList.contains(className), `folder pill should include ${className}`);
|
||||
assert.ok(tagPill.classList.contains(className), `tag pill should include ${className}`);
|
||||
assert.ok(addTagPill.classList.contains(className), `add-tag pill should include ${className}`);
|
||||
}
|
||||
assert.ok(tagPill.classList.contains("text-foreground"));
|
||||
assert.ok(addTagPill.classList.contains("text-foreground"));
|
||||
assert.equal(tagPill.classList.contains("border"), false);
|
||||
assert.equal(tagPill.classList.contains("bg-primary/10"), false);
|
||||
assert.equal(folderPill.querySelector("svg")?.classList.contains("-translate-y-px"), false);
|
||||
assert.equal(tagPill.querySelector("svg")?.classList.contains("-translate-y-px"), false);
|
||||
assert.ok([...folderPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
|
||||
assert.ok([...tagPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
|
||||
assert.ok([...addTagPill.querySelectorAll("span")].some((node) => node.classList.contains("translate-y-px")));
|
||||
});
|
||||
|
||||
test("NotesManager tree scroll area constrains width so titles can truncate", () => {
|
||||
@@ -158,36 +114,6 @@ test("clampNotesTreeWidth keeps the sidebar within the design range", () => {
|
||||
assert.equal(clampNotesTreeWidth(900), 520);
|
||||
});
|
||||
|
||||
test("normalizeNoteEditorMode migrates the legacy live mode to edit", () => {
|
||||
assert.equal(normalizeNoteEditorMode("live"), "edit");
|
||||
assert.equal(isNoteEditorMode("live"), false);
|
||||
assert.equal(isNoteEditorMode("edit"), true);
|
||||
assert.equal(normalizeNoteEditorMode("preview"), "preview");
|
||||
assert.equal(normalizeNoteEditorMode("invalid"), null);
|
||||
});
|
||||
|
||||
test("stored legacy live mode falls back to edit on the real read path", (t) => {
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => key === STORAGE_KEY_VAULT_NOTES_EDITOR_MODE ? "live" : null,
|
||||
} as Storage,
|
||||
});
|
||||
t.after(() => {
|
||||
if (previousLocalStorage) {
|
||||
Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
readStoredStringValue(STORAGE_KEY_VAULT_NOTES_EDITOR_MODE, "edit", isNoteEditorMode),
|
||||
"edit",
|
||||
);
|
||||
});
|
||||
|
||||
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
|
||||
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
|
||||
const noteSelection = getNoteSelectionState(notes[0], false);
|
||||
@@ -425,6 +351,4 @@ test("NotesManager can re-open the same sidebar note when the request id changes
|
||||
|
||||
assert.match(source, /openNoteRequestId\?: number \| null/);
|
||||
assert.match(source, /\[isSidebarMode, onOpenNoteIdHandled, openNoteId, openNoteRequestId, sortedNotes\]/);
|
||||
assert.match(source, /if \(isSidebarMode && overlayNoteView\)/);
|
||||
assert.match(source, /\[isSidebarMode, overlayNoteView\]/);
|
||||
});
|
||||
+102
-407
@@ -6,13 +6,12 @@ import {
|
||||
FileText,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Hash,
|
||||
ListTree,
|
||||
Glasses,
|
||||
MoreHorizontal,
|
||||
Minimize2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -21,12 +20,6 @@ import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
|
||||
import { useStoredNumber } from "../../application/state/useStoredNumber";
|
||||
import { useStoredString } from "../../application/state/useStoredString";
|
||||
import { useAvailableFonts } from "../../application/state/fontStore";
|
||||
import { resolveNoteFontFamily } from "../../domain/noteFonts";
|
||||
import { NoteOutline } from "./NoteOutline";
|
||||
import { NoteExportMenu } from "./NoteExportMenu";
|
||||
import { NoteModeDropdown, NoteToolbar } from "./NoteToolbar";
|
||||
import type { NoteSourceEditorHandle } from "./NoteSourceEditor";
|
||||
import {
|
||||
ancestorNoteGroupPaths,
|
||||
buildVaultNoteMarkdownExportFiles,
|
||||
@@ -42,16 +35,11 @@ import {
|
||||
replaceNoteGroupPrefix,
|
||||
resolveMovedNoteGroupPath,
|
||||
sanitizeNoteExportFileNamePart,
|
||||
type MarkdownActionType,
|
||||
type NoteHeadingItem,
|
||||
type VaultNotesExportScope,
|
||||
} from "../../domain/notes";
|
||||
import { getNextVaultOrder, reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from "../../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_NOTES_EDITOR_MODE,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { logger } from "../../lib/logger";
|
||||
@@ -88,21 +76,16 @@ import {
|
||||
markVaultDropIndicator,
|
||||
markVaultInsideDropIndicator,
|
||||
} from "../vault/vaultReorderDrag";
|
||||
import type {
|
||||
ActiveTextFormats,
|
||||
InlineMarkdownEditorHandle,
|
||||
NoteEditorMode,
|
||||
} from "./noteEditorTypes";
|
||||
import { EMPTY_ACTIVE_FORMATS } from "./noteEditorTypes";
|
||||
import type { NoteEditorMode } from "./InlineMarkdownEditor";
|
||||
import { NoteTitleInput } from "./NoteTitleInput";
|
||||
|
||||
const InlineMarkdownEditor = lazy(() =>
|
||||
import("./InlineMarkdownEditor.lazy").then((module) => ({ default: module.InlineMarkdownEditor })),
|
||||
import("./InlineMarkdownEditor").then((module) => ({ default: module.InlineMarkdownEditor })),
|
||||
);
|
||||
|
||||
/** Warm the MDXEditor chunk before Suspense. */
|
||||
export function prefetchInlineMarkdownEditor(): void {
|
||||
void import("./InlineMarkdownEditor.lazy");
|
||||
void import("./InlineMarkdownEditor");
|
||||
}
|
||||
|
||||
interface NoteFolderNode {
|
||||
@@ -116,8 +99,6 @@ type NotesToolbarPanel = "search" | null;
|
||||
|
||||
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
|
||||
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
|
||||
const noteMetadataPillClass = "inline-flex h-5 items-center gap-1 rounded-md bg-muted/70 px-2 text-[11px] font-medium leading-none";
|
||||
const noteMetadataLabelClass = "translate-y-px";
|
||||
const NOTES_TREE_DEFAULT_WIDTH = 300;
|
||||
/** Narrow enough for nested folders + ellipsis; toolbar scrolls if needed. */
|
||||
const NOTES_TREE_MIN_WIDTH = 160;
|
||||
@@ -130,14 +111,10 @@ export function clampNotesTreeWidth(value: number): number {
|
||||
}
|
||||
|
||||
export const normalizeNoteEditorMode = (value: string | null): NoteEditorMode | null =>
|
||||
value === "live"
|
||||
? "edit"
|
||||
: value === "edit" || value === "preview" || value === "source"
|
||||
? value
|
||||
: null;
|
||||
value === "edit" || value === "preview" ? value : null;
|
||||
|
||||
export const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
|
||||
value === "edit" || value === "preview" || value === "source";
|
||||
const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
|
||||
normalizeNoteEditorMode(value) !== null;
|
||||
|
||||
const InlineMarkdownEditorFallback = () => (
|
||||
<div
|
||||
@@ -395,7 +372,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
"edit",
|
||||
isNoteEditorMode,
|
||||
);
|
||||
const [activeFormats, setActiveFormats] = useState<ActiveTextFormats>(EMPTY_ACTIVE_FORMATS);
|
||||
const [overlayNoteId, setOverlayNoteId] = useState<string | null>(() => initialOpenNoteId);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
() => new Set(notes.flatMap((note) => note.group ? ancestorNoteGroupPaths(note.group) : [])),
|
||||
@@ -412,41 +388,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [showOutline, setShowOutline] = useState(false);
|
||||
const [tagInputOpen, setTagInputOpen] = useState(false);
|
||||
const [newTagText, setNewTagText] = useState("");
|
||||
const [treeWidth, setTreeWidth, persistTreeWidth] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
|
||||
NOTES_TREE_DEFAULT_WIDTH,
|
||||
{ min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH },
|
||||
);
|
||||
const [noteFontFamily, setNoteFontFamily] = useStoredString<string>(
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
"",
|
||||
);
|
||||
const availableNoteFonts = useAvailableFonts();
|
||||
const resolvedNoteFontFamily = useMemo(
|
||||
() => resolveNoteFontFamily(availableNoteFonts, noteFontFamily),
|
||||
[availableNoteFonts, noteFontFamily],
|
||||
);
|
||||
const [noteFontSize, setNoteFontSize, persistNoteFontSize] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
14,
|
||||
{ min: 10, max: 32 },
|
||||
);
|
||||
const handleSetNoteFontSize = useCallback((size: number) => {
|
||||
setNoteFontSize(size);
|
||||
persistNoteFontSize(size);
|
||||
}, [persistNoteFontSize, setNoteFontSize]);
|
||||
const [noteCodeFontSize, setNoteCodeFontSize, persistNoteCodeFontSize] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
13,
|
||||
{ min: 10, max: 32 },
|
||||
);
|
||||
const handleSetNoteCodeFontSize = useCallback((size: number) => {
|
||||
setNoteCodeFontSize(size);
|
||||
persistNoteCodeFontSize(size);
|
||||
}, [persistNoteCodeFontSize, setNoteCodeFontSize]);
|
||||
const treeAsideRef = useRef<HTMLElement | null>(null);
|
||||
const treeWidthRef = useRef(treeWidth);
|
||||
treeWidthRef.current = treeWidth;
|
||||
@@ -472,26 +418,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
return cleaned;
|
||||
}, [onUpdateNotes]);
|
||||
|
||||
const addTagToNote = useCallback((noteId: string, tag: string) => {
|
||||
const clean = tag.trim();
|
||||
if (!clean) return;
|
||||
commitNotes(sortedNotesRef.current.map((n) => {
|
||||
if (n.id !== noteId) return n;
|
||||
const existing = n.tags ?? [];
|
||||
if (existing.includes(clean)) return n;
|
||||
return { ...n, tags: [...existing, clean], updatedAt: Date.now() };
|
||||
}));
|
||||
}, [commitNotes]);
|
||||
|
||||
const removeTagFromNote = useCallback((noteId: string, tagToRemove: string) => {
|
||||
commitNotes(sortedNotesRef.current.map((n) => {
|
||||
if (n.id !== noteId) return n;
|
||||
const existing = n.tags ?? [];
|
||||
const nextTags = existing.filter((t) => t !== tagToRemove);
|
||||
return { ...n, tags: nextTags.length ? nextTags : undefined, updatedAt: Date.now() };
|
||||
}));
|
||||
}, [commitNotes]);
|
||||
|
||||
const NOTE_DRAFT_DEBOUNCE_MS = 300;
|
||||
const draftNoteIdRef = useRef<string | null>(null);
|
||||
const draftTitleRef = useRef<string | null>(null);
|
||||
@@ -597,21 +523,18 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}, [groupOrderByPath, groups, sortedNotes]);
|
||||
const selectedNote = getSelectedVaultNote(sortedNotes, selectedNoteId);
|
||||
const overlayNote = sortedNotes.find((note) => note.id === overlayNoteId) ?? null;
|
||||
const selectedNoteView = useMemo(() => {
|
||||
if (!selectedNote) return null;
|
||||
if (draftNoteId === selectedNote.id && draftTitle !== null) {
|
||||
return { ...selectedNote, title: draftTitle };
|
||||
}
|
||||
return selectedNote;
|
||||
}, [draftNoteId, draftTitle, selectedNote]);
|
||||
|
||||
const overlayNoteView = useMemo(() => {
|
||||
if (!overlayNote) return null;
|
||||
if (draftNoteId === overlayNote.id && draftTitle !== null) {
|
||||
return { ...overlayNote, title: draftTitle };
|
||||
}
|
||||
return overlayNote;
|
||||
}, [draftNoteId, draftTitle, overlayNote]);
|
||||
const selectedNoteView = selectedNote && draftNoteId === selectedNote.id
|
||||
? {
|
||||
...selectedNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
}
|
||||
: selectedNote;
|
||||
const overlayNoteView = overlayNote && draftNoteId === overlayNote.id
|
||||
? {
|
||||
...overlayNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
}
|
||||
: overlayNote;
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftNoteIdRef.current) return;
|
||||
@@ -744,21 +667,34 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
onOpenHost?.(host, { noteId });
|
||||
}, [onOpenHost]);
|
||||
|
||||
const sourceEditorRef = useRef<NoteSourceEditorHandle>(null);
|
||||
const inlineEditorRef = useRef<InlineMarkdownEditorHandle>(null);
|
||||
const overlayEditorRef = useRef<InlineMarkdownEditorHandle>(null);
|
||||
const renderNoteModeToggle = () => {
|
||||
const label = noteEditorMode === "edit" ? t("notes.mode.preview") : t("notes.mode.edit");
|
||||
const Icon = noteEditorMode === "edit" ? Glasses : PencilLine;
|
||||
|
||||
const handleToolbarAction = useCallback((action: MarkdownActionType) => {
|
||||
if (isSidebarMode && overlayNoteView) {
|
||||
overlayEditorRef.current?.executeAction(action);
|
||||
} else {
|
||||
inlineEditorRef.current?.executeAction(action);
|
||||
}
|
||||
}, [isSidebarMode, overlayNoteView]);
|
||||
|
||||
const handleOutlineHeadingSelect = useCallback((heading: NoteHeadingItem, index: number) => {
|
||||
inlineEditorRef.current?.scrollToHeading(heading, index);
|
||||
}, []);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-note-mode-switch
|
||||
aria-label={label}
|
||||
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
|
||||
onClick={() => {
|
||||
flushNoteDraft();
|
||||
setNoteEditorMode((currentMode) => (
|
||||
currentMode === "edit" ? "preview" : "edit"
|
||||
));
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const addNoteToGroup = (group: string | null) => {
|
||||
let created: VaultNote | null = null;
|
||||
@@ -925,6 +861,24 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
exportNotesToZip({ type: "group", group: groupPath }, groupPath);
|
||||
}, [exportNotesToZip]);
|
||||
|
||||
const renderNoteExportButton = (note: VaultNote) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("notes.action.exportNote")}
|
||||
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
|
||||
onClick={() => exportNoteToMarkdown(note)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("notes.action.exportNote")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const duplicateNoteById = (noteId: string) => {
|
||||
flushNoteDraft();
|
||||
const source = sortedNotesRef.current.find((note) => note.id === noteId);
|
||||
@@ -1290,7 +1244,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
style={{ paddingLeft: depth * 16 + 4 }}
|
||||
>
|
||||
<div className="mr-1 h-5 w-4 shrink-0" />
|
||||
<div className="mr-1 flex shrink-0 items-center text-current">
|
||||
<div className="mr-2 flex h-5 w-5 shrink-0 items-center justify-center text-current">
|
||||
<Folder size={16} strokeWidth={1.9} />
|
||||
</div>
|
||||
<VaultTreeInlineRenameInput
|
||||
@@ -1320,8 +1274,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
renameNoteFromTree(note.id, name);
|
||||
}}
|
||||
onRenameCancel={() => setEditingNoteId(null)}
|
||||
icon={<FileText size={16} className="shrink-0 text-muted-foreground" />}
|
||||
iconClassName="mr-1"
|
||||
icon={<FileText size={16} className="mr-2 shrink-0 text-muted-foreground" />}
|
||||
data-note-id={note.id}
|
||||
data-notes-drag-kind="note"
|
||||
data-notes-context-menu="note"
|
||||
@@ -1398,7 +1351,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
selected={isNoteFolderTreeSelected(selectedGroup, selectedNoteId, node.path)}
|
||||
hasChildren={hasChildren}
|
||||
iconSize={16}
|
||||
iconClassName="mr-1"
|
||||
editing={editingGroupPath === node.path}
|
||||
editingInitialName={node.name}
|
||||
onRenameCommit={(name) => renameGroup(node.path, name)}
|
||||
@@ -1790,7 +1742,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
>
|
||||
{selectedNoteView ? (
|
||||
<>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-2 px-8 pt-6 pb-1" data-note-title-row>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
|
||||
<div className="min-w-0 flex-1">
|
||||
<NoteTitleInput
|
||||
noteId={selectedNoteView.id}
|
||||
@@ -1802,226 +1754,35 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NoteModeDropdown
|
||||
editorMode={noteEditorMode}
|
||||
onChangeMode={(mode) => {
|
||||
flushNoteDraft();
|
||||
setNoteEditorMode(mode);
|
||||
{renderNoteExportButton(selectedNoteView)}
|
||||
{renderNoteModeToggle()}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
className="min-h-full w-full px-8 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Outline Toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("notes.outline.title")}
|
||||
className={cn(
|
||||
"app-no-drag h-8 w-8 shrink-0 rounded-md p-0 transition-colors hover:bg-secondary/70",
|
||||
showOutline ? "bg-secondary text-primary font-medium" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setShowOutline((prev) => !prev)}
|
||||
>
|
||||
<ListTree size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("notes.outline.title")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Export Menu */}
|
||||
<NoteExportMenu
|
||||
note={selectedNoteView}
|
||||
allNotes={sortedNotes}
|
||||
onExportNote={exportNoteToMarkdown}
|
||||
onExportAll={exportAllNotes}
|
||||
/>
|
||||
|
||||
{/* Delete Note */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("action.delete")}
|
||||
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => requestDeleteNoteById(selectedNoteView.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("action.delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Tags & Folder Breadcrumb Header */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-8 pb-2 text-xs text-muted-foreground">
|
||||
{selectedNoteView.group && (
|
||||
<span
|
||||
data-note-metadata-pill="folder"
|
||||
className={cn(noteMetadataPillClass, "text-foreground")}
|
||||
>
|
||||
<Folder size={12} className="text-amber-500" />
|
||||
<span className={noteMetadataLabelClass}>{selectedNoteView.group}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{selectedNoteView.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
data-note-metadata-pill="tag"
|
||||
className={cn(noteMetadataPillClass, "text-foreground")}
|
||||
>
|
||||
<Hash size={12} className="opacity-70" />
|
||||
<span className={noteMetadataLabelClass}>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-0.5 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground"
|
||||
onClick={() => removeTagFromNote(selectedNoteView.id, tag)}
|
||||
title={t("notes.tag.remove")}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{tagInputOpen ? (
|
||||
<div className="inline-flex h-5 items-center gap-1 rounded-md border border-primary/60 bg-background px-1.5 leading-none">
|
||||
<Hash size={12} className="text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newTagText}
|
||||
onChange={(e) => setNewTagText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (newTagText.trim()) {
|
||||
addTagToNote(selectedNoteView.id, newTagText.trim());
|
||||
setNewTagText("");
|
||||
setTagInputOpen(false);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setTagInputOpen(false);
|
||||
setNewTagText("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (newTagText.trim()) {
|
||||
addTagToNote(selectedNoteView.id, newTagText.trim());
|
||||
}
|
||||
setTagInputOpen(false);
|
||||
setNewTagText("");
|
||||
}}
|
||||
placeholder={t("notes.tag.placeholder")}
|
||||
className="w-20 translate-y-px bg-transparent text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-note-metadata-pill="add-tag"
|
||||
className={cn(noteMetadataPillClass, "text-foreground transition-colors hover:bg-muted")}
|
||||
onClick={() => setTagInputOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span className={noteMetadataLabelClass}>{t("notes.tag.add")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cherry Studio Style Note Toolbar */}
|
||||
<NoteToolbar
|
||||
editorMode={noteEditorMode}
|
||||
onAction={handleToolbarAction}
|
||||
activeFormats={activeFormats}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
onChangeNoteFontFamily={(font) => setNoteFontFamily(font)}
|
||||
noteFontSize={noteFontSize}
|
||||
onChangeNoteFontSize={handleSetNoteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onChangeNoteCodeFontSize={handleSetNoteCodeFontSize}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 min-h-0 min-w-0">
|
||||
{noteEditorMode === "source" ? (
|
||||
<div
|
||||
className="flex-1 min-h-0 min-w-0 h-full"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
ref={inlineEditorRef}
|
||||
noteId={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
sourceEditorRef={sourceEditorRef}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
noteFontSize={noteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onActiveFormatsChange={setActiveFormats}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
className="min-h-full w-full px-8 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
ref={inlineEditorRef}
|
||||
noteId={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
sourceEditorRef={sourceEditorRef}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
noteFontSize={noteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onActiveFormatsChange={setActiveFormats}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{showOutline && (
|
||||
<div className="h-full w-64 shrink-0">
|
||||
<NoteOutline
|
||||
content={selectedNoteView.content}
|
||||
onSelectHeading={handleOutlineHeadingSelect}
|
||||
onClose={() => setShowOutline(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
noteId={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-4">
|
||||
@@ -2072,7 +1833,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-2 px-4 pt-5 pb-1" data-note-title-row>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-4 pt-5 pb-1" data-note-title-row>
|
||||
<div className="min-w-0 flex-1">
|
||||
<NoteTitleInput
|
||||
noteId={overlayNoteView.id}
|
||||
@@ -2084,39 +1845,12 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NoteModeDropdown
|
||||
editorMode={noteEditorMode}
|
||||
onChangeMode={(mode) => {
|
||||
flushNoteDraft();
|
||||
setNoteEditorMode(mode);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NoteExportMenu
|
||||
note={overlayNoteView}
|
||||
allNotes={sortedNotes}
|
||||
onExportNote={exportNoteToMarkdown}
|
||||
onExportAll={exportAllNotes}
|
||||
/>
|
||||
{renderNoteExportButton(overlayNoteView)}
|
||||
{renderNoteModeToggle()}
|
||||
</div>
|
||||
|
||||
{/* Note Toolbar in Overlay */}
|
||||
<NoteToolbar
|
||||
editorMode={noteEditorMode}
|
||||
onAction={handleToolbarAction}
|
||||
activeFormats={activeFormats}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
onChangeNoteFontFamily={(font) => setNoteFontFamily(font)}
|
||||
noteFontSize={noteFontSize}
|
||||
onChangeNoteFontSize={handleSetNoteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onChangeNoteCodeFontSize={handleSetNoteCodeFontSize}
|
||||
/>
|
||||
|
||||
{noteEditorMode === "source" ? (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
className="flex-1 min-h-0 min-w-0 h-full"
|
||||
className="min-h-full w-full px-4 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
@@ -2126,7 +1860,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-overlay-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
ref={overlayEditorRef}
|
||||
noteId={overlayNoteView.id}
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
@@ -2136,49 +1869,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
sourceEditorRef={sourceEditorRef}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
noteFontSize={noteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onActiveFormatsChange={setActiveFormats}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
className="min-h-full w-full px-4 pt-2 pb-6"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LazyLoadBoundary name="Notes editor" resetKey="notes-overlay-editor">
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
ref={overlayEditorRef}
|
||||
noteId={overlayNoteView.id}
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
onChange={(content) => saveNoteContentDraft(overlayNoteView.id, content)}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
sourceEditorRef={sourceEditorRef}
|
||||
noteFontFamily={resolvedNoteFontFamily}
|
||||
noteFontSize={noteFontSize}
|
||||
noteCodeFontSize={noteCodeFontSize}
|
||||
onActiveFormatsChange={setActiveFormats}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { MarkdownActionType, NoteHeadingItem } from "../../domain/notes";
|
||||
|
||||
export interface InlineMarkdownEditorHandle {
|
||||
executeAction: (action: MarkdownActionType) => void;
|
||||
focus: () => void;
|
||||
scrollToHeading: (heading: NoteHeadingItem, headingIndex: number) => boolean;
|
||||
}
|
||||
|
||||
export type NoteEditorMode = "edit" | "preview" | "source" | "live";
|
||||
|
||||
/** Active text-format toggles at the current selection (toolbar highlight). */
|
||||
export type ActiveTextFormats = {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strikethrough: boolean;
|
||||
code: boolean;
|
||||
};
|
||||
|
||||
export const EMPTY_ACTIVE_FORMATS: ActiveTextFormats = {
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
code: false,
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
@use "katex/src/styles/katex.scss" with (
|
||||
$font-folder: "katex/dist/fonts",
|
||||
$use-woff: false,
|
||||
$use-ttf: false,
|
||||
);
|
||||
@@ -1,62 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
normalizeNoteMathSource,
|
||||
NOTE_MATH_KATEX_OPTIONS,
|
||||
renderNoteMathFormula,
|
||||
} from "./noteMathRenderer.ts";
|
||||
|
||||
test("note math renderer uses accessible, untrusted KaTeX output", () => {
|
||||
assert.equal(NOTE_MATH_KATEX_OPTIONS.displayMode, true);
|
||||
assert.equal(NOTE_MATH_KATEX_OPTIONS.output, "htmlAndMathml");
|
||||
assert.equal(NOTE_MATH_KATEX_OPTIONS.throwOnError, false);
|
||||
assert.equal(NOTE_MATH_KATEX_OPTIONS.trust, false);
|
||||
|
||||
const html = renderNoteMathFormula(String.raw`E = mc^2`);
|
||||
assert.match(html, /class="katex-display"/);
|
||||
assert.match(html, /class="katex-mathml"/);
|
||||
assert.match(html, /<msup>/);
|
||||
});
|
||||
|
||||
test("note math renderer handles prime shorthand and matrices", () => {
|
||||
const primes = renderNoteMathFormula(String.raw`f'(x) + x''`);
|
||||
assert.match(primes, /<msup>/);
|
||||
assert.match(primes, /′/);
|
||||
|
||||
const matrix = renderNoteMathFormula(String.raw`\begin{pmatrix} a & b \\ c & d \end{pmatrix}^{-1}`);
|
||||
assert.match(matrix, /<mtable(?:\s|>)/);
|
||||
assert.match(matrix, /<msup>/);
|
||||
});
|
||||
|
||||
test("note math renderer supports common LaTeX constructs without custom parsing", () => {
|
||||
const html = renderNoteMathFormula(
|
||||
String.raw`\sum_{i=1}^n \frac{\alpha_i}{\sqrt[3]{x_i}} \times \left\lVert v \right\rVert`,
|
||||
);
|
||||
assert.match(html, /∑/);
|
||||
assert.match(html, /<mfrac>/);
|
||||
assert.match(html, /<mroot>/);
|
||||
assert.match(html, /α/);
|
||||
assert.match(html, /∥/);
|
||||
});
|
||||
|
||||
test("note math renderer accepts balanced outer display delimiters only", () => {
|
||||
assert.equal(normalizeNoteMathSource(" $$ x^2 $$ "), "x^2");
|
||||
assert.equal(normalizeNoteMathSource(String.raw`\[ x^2 \]`), "x^2");
|
||||
assert.equal(normalizeNoteMathSource("$$ x^2"), "$$ x^2");
|
||||
assert.equal(normalizeNoteMathSource("x^2 $$"), "x^2 $$");
|
||||
});
|
||||
|
||||
test("note math renderer shows invalid input without throwing", () => {
|
||||
const html = renderNoteMathFormula(String.raw`\frac{`);
|
||||
assert.match(html, /class="katex-error"/);
|
||||
assert.match(html, /\\frac/);
|
||||
});
|
||||
|
||||
test("note math renderer blocks untrusted links and external images", () => {
|
||||
const link = renderNoteMathFormula(String.raw`\href{javascript:alert(1)}{click}`);
|
||||
assert.doesNotMatch(link, /href=/i);
|
||||
|
||||
const image = renderNoteMathFormula(String.raw`\includegraphics{https://example.com/x.png}`);
|
||||
assert.doesNotMatch(image, /<img/i);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import katex, { type KatexOptions } from "katex";
|
||||
|
||||
export const NOTE_MATH_KATEX_OPTIONS: KatexOptions = Object.freeze({
|
||||
displayMode: true,
|
||||
output: "htmlAndMathml",
|
||||
throwOnError: false,
|
||||
strict: "warn",
|
||||
trust: false,
|
||||
maxExpand: 1_000,
|
||||
maxSize: 20,
|
||||
});
|
||||
|
||||
export const normalizeNoteMathSource = (source: string): string => {
|
||||
const trimmed = source.trim();
|
||||
if (trimmed.length >= 4 && trimmed.startsWith("$$") && trimmed.endsWith("$$")) {
|
||||
return trimmed.slice(2, -2).trim();
|
||||
}
|
||||
if (trimmed.length >= 4 && trimmed.startsWith("\\[") && trimmed.endsWith("\\]")) {
|
||||
return trimmed.slice(2, -2).trim();
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export const renderNoteMathFormula = (source: string): string => katex.renderToString(
|
||||
normalizeNoteMathSource(source),
|
||||
NOTE_MATH_KATEX_OPTIONS,
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("terminal font dropdown always preserves an explicit default option", () => {
|
||||
const source = readFileSync(new URL("./TerminalFontSelect.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /font\.id === "" \|\| font\.id === value/);
|
||||
});
|
||||
@@ -51,7 +51,7 @@ export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
|
||||
// the version (isFontInstalled reads module state).
|
||||
void availabilityVersion;
|
||||
const filtered = fonts.filter(
|
||||
(font) => font.id === "" || font.id === value || isFontInstalled(extractPrimaryFamily(font.family)),
|
||||
(font) => font.id === value || isFontInstalled(extractPrimaryFamily(font.family)),
|
||||
);
|
||||
if (hasAuthoritativeData()) return filtered;
|
||||
return filtered.length >= 1 ? filtered : fonts;
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import React, { memo, useCallback, useState } from "react";
|
||||
import { applyCustomCssToDocument } from "../../../lib/customCss";
|
||||
import { DebouncedTextarea } from "../DebouncedTextarea";
|
||||
import { Check, HelpCircle, Monitor, Moon, Palette, Sun } from "lucide-react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
|
||||
import { useStoredString } from "../../../application/state/useStoredString";
|
||||
import { useStoredNumber } from "../../../application/state/useStoredNumber";
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
|
||||
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
|
||||
import { useAvailableFonts } from "../../../application/state/fontStore";
|
||||
import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
|
||||
import { APP_ICON_VARIANT_ASSET_PATH, APP_ICON_VARIANT_GROUPS, APP_ICON_VARIANT_I18N_KEY } from "../../../infrastructure/config/appIconVariants";
|
||||
import {
|
||||
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
} from "../../../infrastructure/config/storageKeys";
|
||||
import { STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../infrastructure/config/storageKeys";
|
||||
import { resolveAppIconVariant, type AppIconVariant } from "../../../domain/appIconVariant";
|
||||
import { resolveNoteFontSelectionFamily, resolveNoteFontSelectionId } from "../../../domain/noteFonts";
|
||||
import { DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../domain/systemKnownHostsAutoImport";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -71,40 +61,11 @@ function SettingsAppearanceTab(props: {
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
// Note code fonts come from the monospace-only store (fontStore); the note
|
||||
// body font follows the UI font setting instead.
|
||||
const availableMonoFonts = useAvailableFonts();
|
||||
const noteFontOptions = useMemo(() => [
|
||||
{ id: "", name: t("notes.toolbar.defaultFont"), family: "", description: "", category: "monospace" as const },
|
||||
...availableMonoFonts,
|
||||
], [availableMonoFonts, t]);
|
||||
const [customCssHelpOpen, setCustomCssHelpOpen] = useState(false);
|
||||
const [autoImportSystemKnownHosts, setAutoImportSystemKnownHosts] = useStoredBoolean(
|
||||
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
|
||||
DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
|
||||
);
|
||||
const [noteFontFamily, setNoteFontFamily] = useStoredString<string>(
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
"",
|
||||
);
|
||||
const [noteFontSize, setNoteFontSize, persistNoteFontSize] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
14,
|
||||
{ min: 10, max: 32 },
|
||||
);
|
||||
const handleSetNoteFontSize = useCallback((size: number) => {
|
||||
setNoteFontSize(size);
|
||||
persistNoteFontSize(size);
|
||||
}, [persistNoteFontSize, setNoteFontSize]);
|
||||
const [noteCodeFontSize, setNoteCodeFontSize, persistNoteCodeFontSize] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
13,
|
||||
{ min: 10, max: 32 },
|
||||
);
|
||||
const handleSetNoteCodeFontSize = useCallback((size: number) => {
|
||||
setNoteCodeFontSize(size);
|
||||
persistNoteCodeFontSize(size);
|
||||
}, [persistNoteCodeFontSize, setNoteCodeFontSize]);
|
||||
const {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
@@ -503,59 +464,6 @@ function SettingsAppearanceTab(props: {
|
||||
onChange={setAutoImportSystemKnownHosts}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-notes-font"
|
||||
label={t('settings.vault.notesFont')}
|
||||
description={t('settings.vault.notesFontDesc')}
|
||||
>
|
||||
<TerminalFontSelect
|
||||
value={resolveNoteFontSelectionId(noteFontOptions, noteFontFamily)}
|
||||
fonts={noteFontOptions}
|
||||
onChange={(v) => setNoteFontFamily(resolveNoteFontSelectionFamily(noteFontOptions, v))}
|
||||
className="w-48"
|
||||
ariaLabel={t('settings.vault.notesFont')}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-notes-font-size"
|
||||
label={t('settings.vault.notesFontSize')}
|
||||
description={t('settings.vault.notesFontSizeDesc')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={12}
|
||||
max={22}
|
||||
step={1}
|
||||
value={noteFontSize}
|
||||
onChange={(e) => handleSetNoteFontSize(Number(e.target.value))}
|
||||
className="w-28 accent-primary"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
|
||||
{noteFontSize}px
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-notes-code-font-size"
|
||||
label={t('settings.vault.notesCodeFontSize')}
|
||||
description={t('settings.vault.notesCodeFontSizeDesc')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={22}
|
||||
step={1}
|
||||
value={noteCodeFontSize}
|
||||
onChange={(e) => handleSetNoteCodeFontSize(Number(e.target.value))}
|
||||
className="w-28 accent-primary"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
|
||||
{noteCodeFontSize}px
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SettingsAnchor anchorId="appearance-custom-css">
|
||||
|
||||
@@ -155,44 +155,12 @@ test("announces folder replacement risk and refocuses Merge for each queued conf
|
||||
.split(/\s+/)
|
||||
.map((id) => env.document.getElementById(id)?.textContent ?? "")
|
||||
.join(" ");
|
||||
assert.match(describedText, /Merge: keeps destination-only content/);
|
||||
assert.match(describedText, /Replace: deletes destination-only content and cannot be undone/);
|
||||
assert.doesNotMatch(env.document.body.textContent ?? "", /A folder with the same name already exists/);
|
||||
const dialogTitle = env.document.querySelector("[role=dialog] h2");
|
||||
assert.equal(dialogTitle?.textContent, "docs already exists");
|
||||
assert.equal(dialogTitle?.getAttribute("aria-label"), "Folder Conflict: docs already exists");
|
||||
const warning = env.document.getElementById(describedBy.split(/\s+/).at(-1) ?? "");
|
||||
assert.ok(warning, "folder action guidance should render");
|
||||
assert.equal(warning.querySelectorAll("svg").length, 2, "both guidance rows should use matching icons");
|
||||
assert.equal(warning.querySelectorAll("p.text-xs").length, 0, "guidance rows should use the same text size");
|
||||
assert.match(describedText, /A folder with the same name already exists/);
|
||||
assert.match(describedText, /Replace deletes all destination files and sub-folders/);
|
||||
|
||||
const replaceButton = Array.from(env.document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === "Replace");
|
||||
assert.ok(replaceButton, "folder replacement action should render");
|
||||
const mergeButton = Array.from(env.document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === "Merge");
|
||||
assert.ok(mergeButton, "folder merge action should render");
|
||||
assert.match(mergeButton.className, /bg-primary/);
|
||||
assert.match(mergeButton.className, /border/);
|
||||
assert.doesNotMatch(replaceButton.className, /(^|\s)bg-destructive(?:\s|$)/);
|
||||
assert.match(replaceButton.className, /text-destructive/);
|
||||
assert.match(replaceButton.className, /border/);
|
||||
assert.equal(replaceButton.getAttribute("aria-describedby"), describedBy.split(/\s+/).at(-1));
|
||||
for (const label of ["Stop", "Skip", "Duplicate", "Merge", "Replace"]) {
|
||||
const action = Array.from(env.document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === label);
|
||||
assert.ok(action, `${label} action should render`);
|
||||
assert.match(action.className, /h-9/);
|
||||
assert.match(action.className, /min-w-24/);
|
||||
assert.match(action.className, /border/);
|
||||
}
|
||||
assert.equal(
|
||||
Array.from(env.document.querySelectorAll("button"))
|
||||
.filter((button) => button.className.includes("bg-primary"))
|
||||
.length,
|
||||
1,
|
||||
"Merge should be the only visually primary action",
|
||||
);
|
||||
await dispatchDomEvent(replaceButton, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* SFTP Conflict Resolution Dialog
|
||||
*/
|
||||
|
||||
import { AlertTriangle, GitMerge } from 'lucide-react';
|
||||
import { AlertCircle, AlertTriangle } from 'lucide-react';
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { canReplaceSftpConflict, getSftpConflictTypeKey } from '../../domain/sftpConflict';
|
||||
@@ -145,7 +145,6 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
const canMerge = currentCanMerge;
|
||||
const canReplace = currentCanReplace;
|
||||
const presentation = getSftpConflictDialogPresentation(conflict);
|
||||
const showConflictDescription = !presentation.showDirectoryReplaceWarning;
|
||||
const describedBy = presentation.showDirectoryReplaceWarning
|
||||
? `${descriptionId} ${directoryWarningId}`
|
||||
: descriptionId;
|
||||
@@ -158,28 +157,31 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
return (
|
||||
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
|
||||
<DialogContent
|
||||
className="gap-4 p-5 sm:max-w-[600px] sm:p-6"
|
||||
className="gap-5 p-5 sm:max-w-[640px] sm:p-6"
|
||||
aria-describedby={describedBy}
|
||||
>
|
||||
<DialogHeader className="space-y-1.5 pr-8">
|
||||
<DialogTitle
|
||||
className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-lg leading-tight"
|
||||
aria-label={`${t(presentation.titleKey)}: ${conflict.fileName} ${t('sftp.conflict.alreadyExistsSuffix')}`}
|
||||
>
|
||||
<span className="min-w-0 break-words">{conflict.fileName}</span>
|
||||
{' '}
|
||||
<span className="text-base font-normal text-muted-foreground">
|
||||
{t('sftp.conflict.alreadyExistsSuffix')}
|
||||
<DialogHeader className="space-y-2 pr-8">
|
||||
<DialogTitle className="flex items-center gap-3 text-xl leading-tight">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</span>
|
||||
{t(presentation.titleKey)}
|
||||
</DialogTitle>
|
||||
<div id={descriptionId} className={showConflictDescription ? undefined : 'sr-only'}>
|
||||
<DialogDescription className="leading-5">
|
||||
<div id={descriptionId}>
|
||||
<DialogDescription className="text-[15px] leading-6">
|
||||
{t(presentation.descriptionKey)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6">
|
||||
<div className="min-w-0 break-words">
|
||||
<span className="font-medium text-foreground">{conflict.fileName}</span>
|
||||
<span className="ml-1 text-muted-foreground">{t('sftp.conflict.alreadyExistsSuffix')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{presentation.showFileMetadata && (
|
||||
<div className="space-y-3">
|
||||
<ConflictFileSummary
|
||||
@@ -202,16 +204,15 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
{presentation.showDirectoryReplaceWarning && (
|
||||
<div
|
||||
id={directoryWarningId}
|
||||
className="space-y-1.5 text-sm leading-5"
|
||||
className="space-y-2 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm leading-6"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-muted-foreground">
|
||||
<GitMerge className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="flex items-start gap-2 text-foreground">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<p>{t('sftp.conflict.folderMergeHint')}</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-sm font-normal text-destructive/90">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>{t('sftp.conflict.folderReplaceWarning')}</p>
|
||||
</div>
|
||||
<p className="pl-6 font-medium text-destructive">
|
||||
{t('sftp.conflict.folderReplaceWarning')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -228,61 +229,53 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between sm:space-x-0">
|
||||
<DialogFooter className="flex flex-wrap gap-2 sm:flex-nowrap sm:items-center sm:justify-end sm:space-x-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAction('stop')}
|
||||
className="min-w-24 self-start border-border/70 text-muted-foreground hover:text-destructive"
|
||||
className="min-w-24 shrink-0 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
|
||||
>
|
||||
{t('sftp.conflict.action.stop')}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('skip')}
|
||||
className="min-w-24 shrink-0"
|
||||
>
|
||||
{t('sftp.conflict.action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={duplicateButtonRef}
|
||||
variant="outline"
|
||||
onClick={() => handleAction('duplicate')}
|
||||
className="min-w-24 shrink-0"
|
||||
>
|
||||
{t('sftp.conflict.action.duplicate')}
|
||||
</Button>
|
||||
{conflict.isDirectory && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAction('skip')}
|
||||
className="min-w-24 border-border/70"
|
||||
ref={mergeButtonRef}
|
||||
variant={presentation.mergeVariant}
|
||||
onClick={() => handleAction('merge')}
|
||||
disabled={!canMerge}
|
||||
autoFocus={presentation.showDirectoryReplaceWarning}
|
||||
className="min-w-24 shrink-0"
|
||||
>
|
||||
{t('sftp.conflict.action.skip')}
|
||||
{t('sftp.conflict.action.merge')}
|
||||
</Button>
|
||||
)}
|
||||
{canReplace && (
|
||||
<Button
|
||||
ref={duplicateButtonRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAction('duplicate')}
|
||||
className="min-w-24 border-border/70"
|
||||
ref={replaceButtonRef}
|
||||
variant={presentation.replaceVariant}
|
||||
onClick={() => handleAction('replace')}
|
||||
className={presentation.showDirectoryReplaceWarning
|
||||
? 'min-w-28 shrink-0 border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive'
|
||||
: 'min-w-28 shrink-0'}
|
||||
>
|
||||
{t('sftp.conflict.action.duplicate')}
|
||||
{t('sftp.conflict.action.replace')}
|
||||
</Button>
|
||||
{conflict.isDirectory && (
|
||||
<Button
|
||||
ref={mergeButtonRef}
|
||||
variant={presentation.mergeVariant}
|
||||
size="sm"
|
||||
onClick={() => handleAction('merge')}
|
||||
disabled={!canMerge}
|
||||
autoFocus={presentation.showDirectoryReplaceWarning}
|
||||
className="min-w-24 border border-primary"
|
||||
>
|
||||
{t('sftp.conflict.action.merge')}
|
||||
</Button>
|
||||
)}
|
||||
{canReplace && (
|
||||
<Button
|
||||
ref={replaceButtonRef}
|
||||
variant={presentation.replaceVariant}
|
||||
size="sm"
|
||||
onClick={() => handleAction('replace')}
|
||||
aria-describedby={presentation.showDirectoryReplaceWarning ? directoryWarningId : undefined}
|
||||
className={presentation.showDirectoryReplaceWarning
|
||||
? 'min-w-24 border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive'
|
||||
: 'min-w-24'}
|
||||
>
|
||||
{t('sftp.conflict.action.replace')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -78,6 +78,7 @@ test("Ctrl+F opens and refocuses the active SFTP pane filter without handling in
|
||||
showHiddenFiles: false,
|
||||
transferMutationToken: 0,
|
||||
};
|
||||
let refreshCalls = 0;
|
||||
|
||||
const Harness = ({
|
||||
isActive,
|
||||
@@ -93,6 +94,7 @@ test("Ctrl+F opens and refocuses the active SFTP pane filter without handling in
|
||||
const sftpRef = React.useRef({
|
||||
leftTabs: { tabs: [pane], activeTabId: pane.id },
|
||||
rightTabs: { tabs: [], activeTabId: null },
|
||||
refresh: () => { refreshCalls += 1; },
|
||||
} as unknown as SftpStateApi);
|
||||
|
||||
useSftpKeyboardShortcuts({
|
||||
@@ -295,6 +297,25 @@ test("Ctrl+F opens and refocuses the active SFTP pane filter without handling in
|
||||
})).defaultPrevented, true, "the configured PC search shortcut should open the filter");
|
||||
assert.ok(window.document.querySelector('[data-section="terminal-sftp-filter-bar"]'));
|
||||
|
||||
const commandWRefreshBindings = DEFAULT_KEY_BINDINGS.map((binding) => {
|
||||
if (binding.action === "closeTab") return { ...binding, mac: "⌘ + E" };
|
||||
if (binding.action === "sftpRefresh") return { ...binding, mac: "⌘ + W" };
|
||||
return binding;
|
||||
});
|
||||
await act(async () => root.render(React.createElement(Harness, {
|
||||
isActive: true,
|
||||
hotkeyScheme: "mac",
|
||||
keyBindings: commandWRefreshBindings,
|
||||
})));
|
||||
sftpTarget.focus();
|
||||
assert.equal((await pressShortcut(sftpTarget, {
|
||||
key: "w",
|
||||
code: "KeyW",
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
})).defaultPrevented, true, "reassigned Command+W should reach the SFTP handler");
|
||||
assert.equal(refreshCalls, 1);
|
||||
|
||||
await act(async () => root.render(React.createElement(Harness, {
|
||||
isActive: true,
|
||||
hotkeyScheme: "disabled",
|
||||
|
||||
@@ -528,442 +528,6 @@ test("a keyword written on a saturated scrollback is still colored", async () =>
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("Mosh-style full-screen cursor-addressed repaint keeps highlights", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
|
||||
await write(term, "row-1 ERROR\r\nrow-2 ERROR\r\nrow-3 ERROR\r\nrow-4 ERROR");
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
|
||||
[RED, RED, RED, RED],
|
||||
);
|
||||
|
||||
// Mosh redraws a numbered remote framebuffer with absolute cursor moves.
|
||||
// Once the remote screen fills, an Enter can repaint rows above the local
|
||||
// cursor without emitting newlines or leaving the primary buffer.
|
||||
await write(
|
||||
term,
|
||||
"\x1b[1;1Hrow-2 ERROR\x1b[2;1Hrow-3 ERROR"
|
||||
+ "\x1b[3;1Hrow-4 ERROR\x1b[4;1Hprompt ERROR",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
|
||||
[RED, RED, RED, RED],
|
||||
);
|
||||
|
||||
// PTY chunking can split a cursor-addressing sequence at any byte.
|
||||
await write(term, "\x1b[1;");
|
||||
await write(
|
||||
term,
|
||||
"1Hsplit ERROR\x1b[2;1Hrow-4 ERROR"
|
||||
+ "\x1b[3;1Hprompt ERROR\x1b[4;1Hdone ER",
|
||||
);
|
||||
await write(term, "ROR\x1b[1;1H");
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
|
||||
[RED, RED, RED, RED],
|
||||
);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("Mosh repaint highlights survive every transport split point", async () => {
|
||||
const frame = "\x1b[1;1Hnew-1 ERROR\x1b[2;1Hnew-2 ERROR"
|
||||
+ "\x1b[3;1Hnew-3 ERROR\x1b[4;1Hnew-4 ERROR\x1b[1;1H";
|
||||
for (let split = 1; split < frame.length; split += 1) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1 ERROR\r\nold-2 ERROR\r\nold-3 ERROR\r\nold-4 ERROR");
|
||||
await write(term, frame.slice(0, split));
|
||||
await write(term, frame.slice(split));
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 4 }, (_, y) => cellRgb(term, y, "ERROR")),
|
||||
[RED, RED, RED, RED],
|
||||
`split ${split}`,
|
||||
);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("cursor-addressed repaint follows CRLF rows before restoring the cursor", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
|
||||
await write(term, "\x1b[1;1Hnew-1 ERROR\r\nnew-2 ERROR\r\nnew-3 ERROR\x1b[1;1H");
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 3 }, (_, y) => cellRgb(term, y, "ERROR")),
|
||||
[RED, RED, RED],
|
||||
);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("cursor-addressed repaint keeps pre-scroll rows highlighted", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
|
||||
await write(
|
||||
term,
|
||||
"\x1b[1;1Hscrolled ERROR\r\nline-2\r\nline-3\r\nline-4\r\nline-5\x1b[1;1H",
|
||||
);
|
||||
|
||||
const highlightedRows: number[] = [];
|
||||
for (let y = 0; y < term.buffer.active.length; y += 1) {
|
||||
if (cellRgb(term, y, "ERROR") === RED) highlightedRows.push(y);
|
||||
}
|
||||
assert.deepEqual(highlightedRows, [0]);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("pressured cursor-addressed repaint catches up from the pre-scroll viewport", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
|
||||
noteTerminalOutputPressureData(term, "x".repeat(20_000));
|
||||
await write(
|
||||
term,
|
||||
"\x1b[1;1Hscrolled ERROR\r\nline-2\r\nline-3\r\nline-4\r\nline-5\x1b[1;1H",
|
||||
);
|
||||
await highlighter.whenSettled();
|
||||
|
||||
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("row-sized Mosh repaint writes do not rescan the viewport for every row", async () => {
|
||||
for (const rows of [40, 80]) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, Array.from({ length: rows }, (_, index) => `old-${index} ERROR`).join("\r\n"));
|
||||
|
||||
const internal = highlighter as unknown as {
|
||||
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
|
||||
};
|
||||
const originalRecolorRange = internal.recolorRange.bind(highlighter);
|
||||
let scannedRows = 0;
|
||||
let refreshes = 0;
|
||||
internal.recolorRange = (startY, endY, refresh, force) => {
|
||||
scannedRows += Math.abs(endY - startY) + 1;
|
||||
if (refresh) refreshes += 1;
|
||||
originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
|
||||
for (let row = 1; row <= rows; row += 1) {
|
||||
await write(term, `\x1b[${row};1Hnew-${row} ERROR`);
|
||||
}
|
||||
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), [], `${rows} rows`);
|
||||
assert.ok(
|
||||
scannedRows <= rows * 2,
|
||||
`${rows} rows should stay linear, scanned ${scannedRows} rows`,
|
||||
);
|
||||
assert.ok(
|
||||
refreshes <= rows + 1,
|
||||
`${rows} rows should refresh at most once per typical write, got ${refreshes}`,
|
||||
);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("cursor-restoring Mosh row writes keep repaint work linear", async () => {
|
||||
for (const rows of [40, 80]) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, Array.from({ length: rows }, (_, index) => `old-${index} ERROR`).join("\r\n"));
|
||||
const internal = highlighter as unknown as {
|
||||
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
|
||||
};
|
||||
const originalRecolorRange = internal.recolorRange.bind(highlighter);
|
||||
let scannedRows = 0;
|
||||
let refreshes = 0;
|
||||
internal.recolorRange = (startY, endY, refresh, force) => {
|
||||
scannedRows += Math.abs(endY - startY) + 1;
|
||||
if (refresh) refreshes += 1;
|
||||
originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
|
||||
for (let row = 1; row <= rows; row += 1) {
|
||||
await write(term, `\x1b[${row};1Hnew-${row} ERROR\x1b[1;1H`);
|
||||
}
|
||||
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), [], `${rows} rows`);
|
||||
assert.ok(scannedRows <= rows * 2, `${rows} rows scanned ${scannedRows}`);
|
||||
assert.ok(refreshes <= rows * 2, `${rows} rows refreshed ${refreshes} times`);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("CUP fragments survive enabling highlighting between writes", async () => {
|
||||
for (const control of ["\x1b[2;1H", "\x9b2;1H"]) {
|
||||
for (let split = 1; split < control.length; split += 1) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), false);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, control.slice(0, split));
|
||||
highlighter.setRules(rule(), true);
|
||||
await highlighter.whenSettled();
|
||||
await write(term, `${control.slice(split)}target ERROR\x1b[1;1H`);
|
||||
|
||||
assert.equal(cellRgb(term, 1, "ERROR"), RED, `${JSON.stringify(control)} split ${split}`);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("C1 CUP repaints an earlier viewport row", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
|
||||
await write(term, "\x9b2;1Htarget ERROR\x9b5;1H");
|
||||
|
||||
assert.equal(cellRgb(term, 1, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("out-of-range cursor addresses repaint the clamped viewport row", async () => {
|
||||
for (const control of ["\x1b[999;1H", "\x1b[999d", "\x9b999;1H", "\x9b999d"]) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5\x1b[2;1H");
|
||||
|
||||
await write(term, `${control}target ERROR\x1b[2;1H`);
|
||||
|
||||
assert.equal(cellRgb(term, 4, "ERROR"), RED, JSON.stringify(control));
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("cursor-addressed repaint follows VPR and inserted-line row changes", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1 ERROR\r\nold-2 ERROR\r\nold-3 ERROR\r\nold-4 ERROR");
|
||||
|
||||
await write(term, "\x1b[1;1Hfirst ERROR\x1b[2ethird ERROR\x1b[1;1H");
|
||||
await write(term, "\x1b[2;1H\x1b[1Linserted ERROR\x1b[1;1H");
|
||||
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("C1 cursor traversal recolors every visited row", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
|
||||
await write(term, "\x9b1;1Hfirst ERROR\x9b2ethird ERROR\x9b1;1H");
|
||||
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("single-byte C1 row moves recolor every visited row", async () => {
|
||||
for (const control of ["\x84", "\x85", "\x8d"]) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
|
||||
await write(term, `\x9b2;1Hfirst ERROR${control}second ERROR\x9b2;1H`);
|
||||
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), [], JSON.stringify(control));
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("cursor-addressed auto-wrap scrolling keeps the pre-scroll row highlighted", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 12, rows: 4, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4");
|
||||
|
||||
await write(term, `\x1b[4;1HERROR ${"x".repeat(24)}\x1b[1;1H`);
|
||||
|
||||
assert.ok(term.buffer.active.baseY > 0, "fixture must scroll through auto-wrap");
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("origin-mode cursor addresses stay safe across writes and recover after disable", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
|
||||
await write(term, "\x1b[2;4r\x1b[?");
|
||||
await write(term, "6h");
|
||||
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED);
|
||||
|
||||
await write(term, "\x1b[?6l");
|
||||
const internal = highlighter as unknown as {
|
||||
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
|
||||
};
|
||||
const originalRecolorRange = internal.recolorRange.bind(highlighter);
|
||||
let scannedRows = 0;
|
||||
internal.recolorRange = (startY, endY, refresh, force) => {
|
||||
scannedRows += Math.abs(endY - startY) + 1;
|
||||
originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
await write(term, "\x1b[5;1Hnormal ERROR");
|
||||
assert.equal(cellRgb(term, 4, "ERROR"), RED);
|
||||
assert.ok(scannedRows <= 2, `origin-mode disable should restore the narrow path: ${scannedRows}`);
|
||||
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("origin mode is remembered while keyword highlighting is disabled", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), false);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, "\x1b[2;4r\x1b[?6h");
|
||||
|
||||
highlighter.setRules(rule(), true);
|
||||
await highlighter.whenSettled();
|
||||
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
|
||||
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("multi-parameter origin mode is recognized at every transport split", async () => {
|
||||
for (const control of ["\x1b[?6;25h", "\x1b[?25;6h"]) {
|
||||
for (let split = 1; split < control.length; split += 1) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, "\x1b[2;4r");
|
||||
await write(term, control.slice(0, split));
|
||||
await write(term, control.slice(split));
|
||||
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
|
||||
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED, `${JSON.stringify(control)} split ${split}`);
|
||||
await write(term, `${control.slice(0, -1)}l`);
|
||||
const internal = highlighter as unknown as {
|
||||
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
|
||||
};
|
||||
const originalRecolorRange = internal.recolorRange.bind(highlighter);
|
||||
let scannedRows = 0;
|
||||
internal.recolorRange = (startY, endY, refresh, force) => {
|
||||
scannedRows += Math.abs(endY - startY) + 1;
|
||||
originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
await write(term, "\x1b[5;1Hnormal ERROR");
|
||||
assert.ok(scannedRows <= 2, `${JSON.stringify(control)} disable split ${split}: ${scannedRows}`);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("saved origin mode is restored across every cursor-control split", async () => {
|
||||
const controls = [
|
||||
{ save: "\x1b7", restore: "\x1b8", disableHighlightDuringRestore: false },
|
||||
{ save: "\x1b[s", restore: "\x1b[u", disableHighlightDuringRestore: true },
|
||||
];
|
||||
for (const entry of controls) {
|
||||
for (let saveSplit = 1; saveSplit < entry.save.length; saveSplit += 1) {
|
||||
for (let restoreSplit = 1; restoreSplit < entry.restore.length; restoreSplit += 1) {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, "\x1b[2;4r\x1b[?6h");
|
||||
await write(term, entry.save.slice(0, saveSplit));
|
||||
await write(term, entry.save.slice(saveSplit));
|
||||
await write(term, "\x1b[?6l");
|
||||
if (entry.disableHighlightDuringRestore) highlighter.setRules(rule(), false);
|
||||
await write(term, entry.restore.slice(0, restoreSplit));
|
||||
await write(term, entry.restore.slice(restoreSplit));
|
||||
if (entry.disableHighlightDuringRestore) {
|
||||
highlighter.setRules(rule(), true);
|
||||
await highlighter.whenSettled();
|
||||
}
|
||||
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
|
||||
|
||||
const fixture = `${JSON.stringify(entry)} save ${saveSplit} restore ${restoreSplit}`;
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED, fixture);
|
||||
await write(term, "\x1b[?6l");
|
||||
const internal = highlighter as unknown as {
|
||||
recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void;
|
||||
};
|
||||
const originalRecolorRange = internal.recolorRange.bind(highlighter);
|
||||
let scannedRows = 0;
|
||||
internal.recolorRange = (startY, endY, refresh, force) => {
|
||||
scannedRows += Math.abs(endY - startY) + 1;
|
||||
originalRecolorRange(startY, endY, refresh, force);
|
||||
};
|
||||
await write(term, "\x1b[5;1Hnormal ERROR");
|
||||
assert.ok(scannedRows <= 2, `${fixture}: ${scannedRows}`);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("C1 cursor save and restore preserves origin mode", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, "\x9b2;4r\x9b?6;25h\x9bs\x9b?6l\x9bu");
|
||||
|
||||
await write(term, "\x9b2;1Htarget ERROR\x9b1;1H");
|
||||
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("normal-buffer origin mode survives an alternate-screen round trip", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 24, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "old-1\r\nold-2\r\nold-3\r\nold-4\r\nold-5");
|
||||
await write(term, "\x1b[2;4r\x1b[?6h\x1b[?1049h");
|
||||
await write(term, "\x1b[?6l");
|
||||
await write(term, "\x1b[?1049l");
|
||||
await write(term, "\x1b[2;1Htarget ERROR\x1b[1;1H");
|
||||
|
||||
assert.equal(cellRgb(term, 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("scrolling during a rule change catch-up recolors newly visible rows", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 4, scrollback: 80 });
|
||||
let bypass = true;
|
||||
|
||||
@@ -45,11 +45,6 @@ type LogicalLine = {
|
||||
cellAtStringOffset: Array<{ y: number; x: number }>;
|
||||
};
|
||||
|
||||
type AbsoluteRepaintRange = {
|
||||
rows: number[];
|
||||
mayTraverseRows: boolean;
|
||||
};
|
||||
|
||||
export type KeywordHighlighterOptions = {
|
||||
shouldBypassHighlight?: () => boolean;
|
||||
serializeAddon?: SerializeAddon;
|
||||
@@ -185,18 +180,6 @@ const collectMatches = (
|
||||
|
||||
const yieldToRenderer = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const trailingIncompleteCsi = (controls: string): string => {
|
||||
const escapeCsi = controls.lastIndexOf("\x1b[");
|
||||
const c1Csi = controls.lastIndexOf("\x9b");
|
||||
const csiStart = Math.max(escapeCsi, c1Csi);
|
||||
if (csiStart >= 0) {
|
||||
const suffix = controls.slice(csiStart);
|
||||
const body = suffix.startsWith("\x1b[") ? suffix.slice(2) : suffix.slice(1);
|
||||
if (!/[\x40-\x7e]/.test(body)) return suffix.slice(-32);
|
||||
}
|
||||
return controls.endsWith("\x1b") ? "\x1b" : "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Keyword highlighting mutates already-parsed cell foregrounds. Writes stay
|
||||
* pristine, so ordinary Enter/output never rebuilds history, and serialize can
|
||||
@@ -234,12 +217,6 @@ export class KeywordHighlighter implements IDisposable {
|
||||
private lastViewportY = 0;
|
||||
private lastBaseY = 0;
|
||||
private hasOutput = false;
|
||||
private absoluteControlTail = "";
|
||||
private absoluteOriginControlTail = "";
|
||||
private absoluteOriginMode: boolean | null = false;
|
||||
private absoluteActiveBuffer: "normal" | "alternate" | null = "normal";
|
||||
private absoluteNormalSavedOriginMode: boolean | null = false;
|
||||
private absoluteAlternateSavedOriginMode: boolean | null = false;
|
||||
|
||||
get pendingPristineBytes(): number {
|
||||
return 0;
|
||||
@@ -380,8 +357,6 @@ export class KeywordHighlighter implements IDisposable {
|
||||
|
||||
private readonly write: XTerm["write"] = (data, callback) => {
|
||||
if (this.disposed) return this.originalWrite(data, callback);
|
||||
const originModeNeedsSafety = this.trackAbsoluteOriginMode(data);
|
||||
const absoluteControls = this.collectAbsoluteControls(data);
|
||||
const startedOnNormal = this.term.buffer.active.type === "normal";
|
||||
if (!startedOnNormal && this.compiledPatterns.length === 0 && !this.hasPendingCatchUp()) {
|
||||
return this.originalWrite(data, callback);
|
||||
@@ -390,15 +365,11 @@ export class KeywordHighlighter implements IDisposable {
|
||||
if (this.compiledPatterns.length === 0 && !this.hasPendingCatchUp() && startedOnNormal) {
|
||||
return this.originalWrite(data, callback);
|
||||
}
|
||||
const startBaseY = this.term.buffer.active.baseY;
|
||||
const startY = startBaseY + this.term.buffer.active.cursorY;
|
||||
const absoluteRepaintRange = absoluteControls !== null
|
||||
? this.resolveAbsoluteRepaintRange(absoluteControls, originModeNeedsSafety)
|
||||
: null;
|
||||
const startY = this.term.buffer.active.baseY + this.term.buffer.active.cursorY;
|
||||
const bypass = !startedOnNormal || this.shouldBypassWrite(data);
|
||||
if (bypass) {
|
||||
if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0 || this.hasPendingCatchUp())) {
|
||||
this.markCatchUp(absoluteRepaintRange === null ? startY : Math.min(startY, startBaseY));
|
||||
this.markCatchUp(startY);
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
return this.originalWrite(data, () => {
|
||||
@@ -452,77 +423,16 @@ export class KeywordHighlighter implements IDisposable {
|
||||
} else {
|
||||
const startRowMutated = startRowTextBefore !== undefined
|
||||
&& active.getLine(writeMarker.line)?.translateToString(false) !== startRowTextBefore;
|
||||
const ordinaryFromY = skipStartRow && !startRowMutated
|
||||
const fromY = skipStartRow && !startRowMutated
|
||||
? Math.min(endY, writeMarker.line + 1)
|
||||
: writeMarker.line;
|
||||
if (
|
||||
absoluteRepaintRange !== null
|
||||
&& !absoluteRepaintRange.mayTraverseRows
|
||||
&& active.baseY === startBaseY
|
||||
) {
|
||||
// A normal Mosh framebuffer diff sends one absolute row update per
|
||||
// write. Recolor the old cursor row and the addressed rows as
|
||||
// separate ranges so N row-sized writes stay O(N), rather than
|
||||
// rescanning the gaps between them for every write.
|
||||
const repaintRanges = absoluteRepaintRange.rows.map((row) => ({
|
||||
start: startBaseY + row,
|
||||
end: startBaseY + row,
|
||||
}));
|
||||
const includesLine = (line: number) => repaintRanges.some((range) => (
|
||||
line >= range.start && line <= range.end
|
||||
));
|
||||
if (!includesLine(ordinaryFromY)) {
|
||||
repaintRanges.push({ start: ordinaryFromY, end: ordinaryFromY });
|
||||
}
|
||||
if (!includesLine(endY) && endY !== ordinaryFromY) {
|
||||
repaintRanges.push({ start: endY, end: endY });
|
||||
}
|
||||
repaintRanges.sort((left, right) => left.start - right.start);
|
||||
for (let index = repaintRanges.length - 1; index > 0; index -= 1) {
|
||||
const previous = repaintRanges[index - 1];
|
||||
const current = repaintRanges[index];
|
||||
if (current.start > previous.end + 1) continue;
|
||||
previous.end = Math.max(previous.end, current.end);
|
||||
repaintRanges.splice(index, 1);
|
||||
}
|
||||
if (this.compiledPatterns.length === 0) {
|
||||
const catchUpFrom = repaintRanges.reduce<number | null>((earliest, range) => (
|
||||
this.hasStoredOriginalsInRange(range.start, range.end)
|
||||
? Math.min(earliest ?? range.start, range.start)
|
||||
: earliest
|
||||
), null);
|
||||
if (catchUpFrom !== null) {
|
||||
this.markCatchUp(catchUpFrom);
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
} else {
|
||||
for (const range of repaintRanges) {
|
||||
this.recolorRange(range.start, range.end, true, true);
|
||||
}
|
||||
if (this.compiledPatterns.length === 0) {
|
||||
if (this.hasStoredOriginalsInRange(fromY, endY)) {
|
||||
this.markCatchUp(fromY);
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
} else {
|
||||
// An absolute-positioned update that also traverses rows via
|
||||
// CRLF/IND can scroll before restoring its final cursor. Cover the
|
||||
// pre-write and post-write viewports for that uncommon case.
|
||||
const fromY = absoluteRepaintRange === null
|
||||
? ordinaryFromY
|
||||
: Math.min(ordinaryFromY, startBaseY, active.baseY);
|
||||
const toY = absoluteRepaintRange === null
|
||||
? endY
|
||||
: Math.max(
|
||||
ordinaryFromY,
|
||||
endY,
|
||||
startBaseY + this.term.rows - 1,
|
||||
active.baseY + this.term.rows - 1,
|
||||
);
|
||||
if (this.compiledPatterns.length === 0) {
|
||||
if (this.hasStoredOriginalsInRange(fromY, toY)) {
|
||||
this.markCatchUp(fromY);
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
} else {
|
||||
this.recolorRange(fromY, toY, true, true);
|
||||
}
|
||||
this.recolorRange(fromY, endY, true, true);
|
||||
}
|
||||
}
|
||||
} else if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0)) {
|
||||
@@ -534,131 +444,10 @@ export class KeywordHighlighter implements IDisposable {
|
||||
});
|
||||
};
|
||||
|
||||
private trackAbsoluteOriginMode(data: string | Uint8Array): boolean {
|
||||
if (typeof data !== "string") {
|
||||
this.absoluteOriginControlTail = "";
|
||||
this.absoluteOriginMode = null;
|
||||
this.absoluteActiveBuffer = null;
|
||||
this.absoluteNormalSavedOriginMode = null;
|
||||
this.absoluteAlternateSavedOriginMode = null;
|
||||
return true;
|
||||
}
|
||||
const controls = this.absoluteOriginControlTail + data;
|
||||
this.absoluteOriginControlTail = trailingIncompleteCsi(controls);
|
||||
const saveOriginMode = () => {
|
||||
if (this.absoluteActiveBuffer === "normal") {
|
||||
this.absoluteNormalSavedOriginMode = this.absoluteOriginMode;
|
||||
} else if (this.absoluteActiveBuffer === "alternate") {
|
||||
this.absoluteAlternateSavedOriginMode = this.absoluteOriginMode;
|
||||
} else {
|
||||
this.absoluteNormalSavedOriginMode = null;
|
||||
this.absoluteAlternateSavedOriginMode = null;
|
||||
}
|
||||
};
|
||||
const restoreOriginMode = () => {
|
||||
this.absoluteOriginMode = this.absoluteActiveBuffer === "normal"
|
||||
? this.absoluteNormalSavedOriginMode
|
||||
: this.absoluteActiveBuffer === "alternate"
|
||||
? this.absoluteAlternateSavedOriginMode
|
||||
: null;
|
||||
};
|
||||
const originModeControls = [
|
||||
...controls.matchAll(
|
||||
/\x1bc|\x1b[78]|(?:\x1b\[|\x9b)(?:!p|[\d;]*[su]|\?[\d;]*[hl])/g, // eslint-disable-line no-control-regex
|
||||
),
|
||||
];
|
||||
let originModeNeedsSafety = this.absoluteOriginMode !== false;
|
||||
for (const match of originModeControls) {
|
||||
const control = match[0];
|
||||
if (control === "\x1bc") {
|
||||
this.absoluteOriginMode = false;
|
||||
this.absoluteActiveBuffer = "normal";
|
||||
this.absoluteNormalSavedOriginMode = false;
|
||||
this.absoluteAlternateSavedOriginMode = false;
|
||||
} else if (control === "\x1b[!p" || control === "\x9b!p") {
|
||||
this.absoluteOriginMode = false;
|
||||
} else if (control === "\x1b7" || control.endsWith("s")) {
|
||||
saveOriginMode();
|
||||
} else if (control === "\x1b8" || control.endsWith("u")) {
|
||||
restoreOriginMode();
|
||||
} else {
|
||||
const parameters = /^(?:\x1b\[|\x9b)\?([\d;]*)[hl]$/.exec(control)?.[1] // eslint-disable-line no-control-regex
|
||||
?.split(";")
|
||||
.map((parameter) => Number.parseInt(parameter, 10))
|
||||
?? [];
|
||||
const enabled = control.endsWith("h");
|
||||
for (const parameter of parameters) {
|
||||
if (parameter === 6) {
|
||||
this.absoluteOriginMode = enabled;
|
||||
} else if (parameter === 1048) {
|
||||
if (enabled) saveOriginMode();
|
||||
else restoreOriginMode();
|
||||
} else if (parameter === 1049) {
|
||||
if (enabled) {
|
||||
saveOriginMode();
|
||||
this.absoluteActiveBuffer = "alternate";
|
||||
} else {
|
||||
this.absoluteActiveBuffer = "normal";
|
||||
restoreOriginMode();
|
||||
}
|
||||
} else if (parameter === 47 || parameter === 1047) {
|
||||
this.absoluteActiveBuffer = enabled ? "alternate" : "normal";
|
||||
}
|
||||
}
|
||||
}
|
||||
originModeNeedsSafety ||= this.absoluteOriginMode !== false;
|
||||
}
|
||||
return originModeNeedsSafety;
|
||||
}
|
||||
|
||||
private collectAbsoluteControls(data: string | Uint8Array): string | null {
|
||||
if (typeof data !== "string") {
|
||||
this.absoluteControlTail = "";
|
||||
return null;
|
||||
}
|
||||
const controls = this.absoluteControlTail + data;
|
||||
this.absoluteControlTail = trailingIncompleteCsi(controls);
|
||||
return controls;
|
||||
}
|
||||
|
||||
private resolveAbsoluteRepaintRange(
|
||||
controls: string,
|
||||
originModeNeedsSafety: boolean,
|
||||
): AbsoluteRepaintRange | null {
|
||||
const rows = new Set<number>();
|
||||
const noteRow = (raw: string | undefined) => {
|
||||
const row = Math.min(
|
||||
this.term.rows - 1,
|
||||
Math.max(0, (Number.parseInt(raw || "1", 10) || 1) - 1),
|
||||
);
|
||||
rows.add(row);
|
||||
};
|
||||
// CUP/HVP and VPA address a viewport row directly. Mosh's framebuffer
|
||||
// diff uses these controls to repaint rows above the current cursor once
|
||||
// the remote screen fills, without emitting a newline or changing buffer.
|
||||
const cup = /(?:\x1b\[|\x9b)(\d*)(?:;\d*)?[Hf]/g; // eslint-disable-line no-control-regex
|
||||
const vpa = /(?:\x1b\[|\x9b)(\d*)d/g; // eslint-disable-line no-control-regex
|
||||
for (const match of controls.matchAll(cup)) noteRow(match[1]);
|
||||
for (const match of controls.matchAll(vpa)) noteRow(match[1]);
|
||||
// These controls can visit rows that are not named by CUP/VPA. Keep the
|
||||
// wider safety range only for writes that actually contain such movement.
|
||||
const mayTraverseRows = originModeNeedsSafety
|
||||
|| /[\n\v\f\x84\x85\x8d]|\x1b[DEM8]|(?:\x1b\[|\x9b)[\d;?]*[ABEFIJLMSTehlru]/.test(controls); // eslint-disable-line no-control-regex
|
||||
return rows.size === 0
|
||||
? null
|
||||
: { rows: [...rows].sort((left, right) => left - right), mayTraverseRows };
|
||||
}
|
||||
|
||||
private readonly reset: XTerm["reset"] = () => {
|
||||
this.clearStoredOriginals();
|
||||
this.cancelCatchUp();
|
||||
this.hasOutput = false;
|
||||
this.absoluteControlTail = "";
|
||||
this.absoluteOriginControlTail = "";
|
||||
this.absoluteOriginMode = false;
|
||||
this.absoluteActiveBuffer = "normal";
|
||||
this.absoluteNormalSavedOriginMode = false;
|
||||
this.absoluteAlternateSavedOriginMode = false;
|
||||
return this.originalReset();
|
||||
};
|
||||
|
||||
|
||||
@@ -1376,10 +1376,9 @@ test("startMosh defers startup commands until mosh-client is ready", async () =>
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
// Ordinary handshake output must not dismiss the connection overlay before
|
||||
// the interactive Mosh client is ready.
|
||||
// Handshake output dismisses the overlay (connected) but must not send startup input yet.
|
||||
dataCb?.("ssh login banner\r\n", { moshHandshake: true });
|
||||
assert.equal(ctx.hasConnectedRef.current, false);
|
||||
assert.equal(ctx.hasConnectedRef.current, true);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.ok(readyCb, "expected onMoshSessionReady subscription");
|
||||
|
||||
@@ -1390,7 +1389,6 @@ test("startMosh defers startup commands until mosh-client is ready", async () =>
|
||||
|
||||
readyCb?.({ sessionId: "mosh-session" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(ctx.hasConnectedRef.current, true);
|
||||
assert.ok(sent.some((chunk) => chunk.includes("echo from-snippet")));
|
||||
});
|
||||
|
||||
|
||||
@@ -1137,9 +1137,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
// handshake uses an ephemeral SSH PTY first; writing too early lands
|
||||
// input on that PTY and is lost on the swap (issue #2199).
|
||||
//
|
||||
// Keep the progress overlay until mosh-client is ready. The attachment
|
||||
// path still dismisses it early for an interactive password/OTP prompt
|
||||
// so the user can type into the terminal.
|
||||
// Do not gate status=connected on ready: interactive password/OTP
|
||||
// prompts during the SSH handshake need the overlay dismissed so the
|
||||
// user can type into the terminal. Scripts wait on moshShellReady in
|
||||
// Terminal.tsx instead.
|
||||
//
|
||||
// Subscribe BEFORE startMoshSession: a fast passwordless handshake can
|
||||
// emit ready before the await returns, and the event is not replayed.
|
||||
@@ -1155,8 +1156,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
const runMoshStartup = () => {
|
||||
disposeMoshReady?.();
|
||||
disposeMoshReady = undefined;
|
||||
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||||
if (!ctx.hasConnectedRef.current) ctx.updateStatus("connected");
|
||||
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, attachedSessionId, () => {
|
||||
cancelPendingStartupCommand = undefined;
|
||||
});
|
||||
@@ -1227,7 +1226,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
// hibernate detaches exit listeners without closing the session and
|
||||
// would otherwise cancel a still-pending startup command.
|
||||
onExit: cleanupMoshStartupWait,
|
||||
deferConnectionDuringMoshHandshake: Boolean(ctx.terminalBackend.onMoshSessionReady),
|
||||
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||||
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
|
||||
})) {
|
||||
|
||||
@@ -256,8 +256,6 @@ export type TerminalSessionDataMeta = {
|
||||
droppedOutputAlternateScreenAction?: 'enter' | 'leave';
|
||||
/** True while Mosh is still on the ephemeral SSH handshake PTY. */
|
||||
moshHandshake?: boolean;
|
||||
/** The Mosh SSH bootstrap is blocked on input that Netcatty cannot answer automatically. */
|
||||
moshHandshakeRequiresUserInput?: boolean;
|
||||
terminalPerf?: NetcattyTerminalOutputPerfMeta;
|
||||
/** Original host output units acknowledged even when an interceptor changes display length. */
|
||||
pluginPipelineIngressBytes?: number;
|
||||
|
||||
@@ -2449,54 +2449,3 @@ test("attachSessionToTerminal marks connected on metadata-only or visible first
|
||||
assert.deepEqual(statuses, ["connected"]);
|
||||
assert.equal(ctx.hasConnectedRef.current, true);
|
||||
});
|
||||
|
||||
test("Mosh handshake stays connecting until ready unless the backend needs terminal input", () => {
|
||||
const { term } = createFakeTerm();
|
||||
const statuses: string[] = [];
|
||||
let onData: ((data: string, meta?: {
|
||||
moshHandshake?: boolean;
|
||||
moshHandshakeRequiresUserInput?: boolean;
|
||||
}) => void) | null = null;
|
||||
const ctx = {
|
||||
...createContext(false),
|
||||
sessionId: "mosh-session",
|
||||
sessionRef: { current: null as string | null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null as (() => void) | null },
|
||||
disposeExitRef: { current: null as (() => void) | null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
terminalBackend: {
|
||||
onSessionData: (_id: string, cb: typeof onData) => {
|
||||
onData = cb;
|
||||
return () => {};
|
||||
},
|
||||
onSessionExit: () => () => {},
|
||||
writeToSession: () => {},
|
||||
resizeSession: () => {},
|
||||
setSessionFlowPaused: () => {},
|
||||
ackSessionFlow: () => {},
|
||||
},
|
||||
updateStatus: (status: string) => {
|
||||
statuses.push(status);
|
||||
if (status === "connected") ctx.hasConnectedRef.current = true;
|
||||
},
|
||||
setError: () => {},
|
||||
onSessionExit: () => {},
|
||||
};
|
||||
|
||||
attachSessionToTerminal(ctx as never, term, "mosh-session", {
|
||||
deferConnectionDuringMoshHandshake: true,
|
||||
});
|
||||
onData?.("login banner\r\n", { moshHandshake: true });
|
||||
assert.deepEqual(statuses, []);
|
||||
onData?.("Password:", { moshHandshake: true });
|
||||
assert.deepEqual(statuses, []);
|
||||
onData?.("Verification code:", {
|
||||
moshHandshake: true,
|
||||
moshHandshakeRequiresUserInput: true,
|
||||
});
|
||||
assert.deepEqual(statuses, ["connected"]);
|
||||
});
|
||||
@@ -775,7 +775,6 @@ export const tryAttachSessionToTerminal = (
|
||||
onConnected?: (meta?: TerminalSessionDataMeta) => void;
|
||||
onExit?: (evt: TerminalSessionExitEvent) => void;
|
||||
requireExplicitConnectionReady?: boolean;
|
||||
deferConnectionDuringMoshHandshake?: boolean;
|
||||
convertLfToCrlf?: boolean;
|
||||
sudoAutofillPassword?: string;
|
||||
sudoAutofillCandidates?: SudoPasswordAutofillCandidate[];
|
||||
@@ -838,7 +837,6 @@ export const attachSessionToTerminal = (
|
||||
onConnected?: (meta?: TerminalSessionDataMeta) => void;
|
||||
onExit?: (evt: TerminalSessionExitEvent) => void;
|
||||
requireExplicitConnectionReady?: boolean;
|
||||
deferConnectionDuringMoshHandshake?: boolean;
|
||||
convertLfToCrlf?: boolean;
|
||||
sudoAutofillPassword?: string;
|
||||
sudoAutofillCandidates?: SudoPasswordAutofillCandidate[];
|
||||
@@ -887,13 +885,6 @@ export const attachSessionToTerminal = (
|
||||
const markConnectedOnFirstOutput = (meta?: TerminalSessionDataMeta) => {
|
||||
const pluginConnectionReady = meta?.pluginConnectionReady === true;
|
||||
if (opts?.requireExplicitConnectionReady === true && !pluginConnectionReady) return;
|
||||
if (
|
||||
opts?.deferConnectionDuringMoshHandshake === true
|
||||
&& meta?.moshHandshake === true
|
||||
&& meta?.moshHandshakeRequiresUserInput !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (ctx.hasConnectedRef.current && !pluginConnectionReady) return;
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
ctx.updateStatus("connected");
|
||||
@@ -954,9 +945,11 @@ export const attachSessionToTerminal = (
|
||||
data = sudoAutofill?.handleOutput(data) ?? data;
|
||||
writeSessionData(ctx, term, data, ingressBytes, meta);
|
||||
ctx.onTerminalOutput?.(data, meta);
|
||||
// Ordinary transports connect on first visible output. Mosh defers that
|
||||
// transition until ready unless its SSH bootstrap explicitly reports
|
||||
// that it is blocked on input Netcatty could not answer automatically.
|
||||
// Mark connected on first visible output so the connection overlay
|
||||
// dismisses and interactive Mosh handshake prompts (password/OTP)
|
||||
// remain reachable. Startup commands / pending scripts are gated
|
||||
// separately on netcatty:mosh:ready so they do not hit the handshake
|
||||
// PTY (#2199).
|
||||
markConnectedOnFirstOutput(meta);
|
||||
},
|
||||
{ replayBacklog: true },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
@@ -69,20 +68,6 @@ test("VaultTreeItemRow exposes shared selected item state", () => {
|
||||
assert.match(markup, /Failover checklist/);
|
||||
});
|
||||
|
||||
test("VaultTree rows can tighten the icon-to-label gap per surface", () => {
|
||||
const groupMarkup = renderToStaticMarkup(
|
||||
<VaultTreeGroupRow name="Production" depth={0} iconClassName="mr-1" />,
|
||||
);
|
||||
const itemMarkup = renderToStaticMarkup(
|
||||
<VaultTreeItemRow label="Failover checklist" depth={0} iconClassName="mr-1" />,
|
||||
);
|
||||
|
||||
assert.match(groupMarkup, /<div class="flex shrink-0 items-center[^"]*mr-1">/);
|
||||
assert.match(itemMarkup, /<div class="flex shrink-0 items-center[^"]*mr-1">/);
|
||||
assert.doesNotMatch(groupMarkup, /<div class="[^"]*mr-2[^"]*flex shrink-0/);
|
||||
assert.doesNotMatch(itemMarkup, /<div class="[^"]*mr-2[^"]*flex shrink-0/);
|
||||
});
|
||||
|
||||
test("VaultTree labels use CJK-safe line-height under truncate", () => {
|
||||
const groupMarkup = renderToStaticMarkup(
|
||||
<VaultTreeGroupRow name="服务器配置" depth={0} count={1} />,
|
||||
@@ -96,29 +81,6 @@ test("VaultTree labels use CJK-safe line-height under truncate", () => {
|
||||
assert.doesNotMatch(groupMarkup, /leading-none/);
|
||||
assert.match(itemMarkup, /leading-5/);
|
||||
assert.doesNotMatch(itemMarkup, /leading-none/);
|
||||
assert.match(groupMarkup, /flex min-w-0 flex-1 items-center/);
|
||||
assert.match(itemMarkup, /min-w-0 flex-1 truncate leading-5/);
|
||||
assert.match(groupMarkup, /min-w-0 truncate translate-y-px/);
|
||||
assert.match(itemMarkup, /min-w-0 flex-1 truncate leading-5 translate-y-px/);
|
||||
assert.doesNotMatch(groupMarkup, /-translate-y-px/);
|
||||
assert.doesNotMatch(itemMarkup, /-translate-y-px/);
|
||||
});
|
||||
|
||||
test("VaultTreeItemRow does not clip the inline rename input", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<VaultTreeItemRow
|
||||
label="机器安全检查报告"
|
||||
depth={0}
|
||||
editing={true}
|
||||
onRenameCommit={() => undefined}
|
||||
onRenameCancel={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const document = new JSDOM(markup).window.document;
|
||||
const input = document.querySelector<HTMLInputElement>('[data-vault-tree-inline-edit="true"]');
|
||||
assert.ok(input);
|
||||
assert.equal(input.parentElement?.classList.contains("h-5"), false);
|
||||
});
|
||||
|
||||
test("VaultTree labels expose full title and keep actions from shrinking", () => {
|
||||
@@ -141,7 +103,7 @@ test("VaultTree labels expose full title and keep actions from shrinking", () =>
|
||||
assert.match(groupMarkup, /min-w-0 truncate/);
|
||||
assert.match(groupMarkup, /shrink-0[^>]*>[\s\S]*data-row-action="menu"/);
|
||||
assert.match(itemMarkup, /title="Security Audit Report - 完整版"/);
|
||||
assert.match(itemMarkup, /min-w-0 flex-1 truncate leading-5/);
|
||||
assert.match(itemMarkup, /min-w-0 truncate leading-5/);
|
||||
assert.match(itemMarkup, /shrink-0[^>]*>[\s\S]*data-row-action="menu"/);
|
||||
});
|
||||
|
||||
|
||||
@@ -102,7 +102,6 @@ type VaultTreeGroupRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childr
|
||||
labelActions?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
iconSize?: number;
|
||||
iconClassName?: string;
|
||||
meta?: React.ReactNode;
|
||||
rowRef?: React.Ref<HTMLDivElement>;
|
||||
onToggle?: () => void;
|
||||
@@ -123,7 +122,6 @@ export const VaultTreeGroupRow: React.FC<VaultTreeGroupRowProps> = ({
|
||||
labelActions,
|
||||
icon,
|
||||
iconSize = 18,
|
||||
iconClassName,
|
||||
meta,
|
||||
rowRef,
|
||||
className,
|
||||
@@ -148,14 +146,14 @@ export const VaultTreeGroupRow: React.FC<VaultTreeGroupRowProps> = ({
|
||||
data-expanded={expanded ? "true" : "false"}
|
||||
{...props}
|
||||
>
|
||||
<div className="mr-1 flex w-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<div className="mr-1 flex h-5 w-4 flex-shrink-0 items-center justify-center text-muted-foreground">
|
||||
{canExpand && (
|
||||
<div className={cn("transition-transform duration-200", expanded ? "rotate-90" : "")}>
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("mr-2 flex shrink-0 items-center text-current", iconClassName)}>
|
||||
<div className="mr-2 flex h-5 w-5 shrink-0 items-center justify-center text-current transition-colors">
|
||||
{icon ?? (expanded ? (
|
||||
<FolderOpen size={iconSize} strokeWidth={1.9} />
|
||||
) : (
|
||||
@@ -171,8 +169,8 @@ export const VaultTreeGroupRow: React.FC<VaultTreeGroupRowProps> = ({
|
||||
/>
|
||||
) : (
|
||||
// leading-5 (not leading-none): CJK fallbacks like PingFang paint outside a 1.0 em box and get clipped by truncate.
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 leading-5">
|
||||
<span className="min-w-0 truncate translate-y-px" title={name}>{name}</span>
|
||||
<span className="flex min-h-5 min-w-0 flex-1 items-center gap-1.5 leading-5">
|
||||
<span className="min-w-0 truncate" title={name}>{name}</span>
|
||||
{labelActions ? <span className="shrink-0">{labelActions}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
@@ -192,7 +190,6 @@ type VaultTreeItemRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childre
|
||||
depth: number;
|
||||
selected?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
iconClassName?: string;
|
||||
leading?: React.ReactNode;
|
||||
detail?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
@@ -208,7 +205,6 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
|
||||
depth,
|
||||
selected = false,
|
||||
icon,
|
||||
iconClassName,
|
||||
leading,
|
||||
detail,
|
||||
actions,
|
||||
@@ -234,10 +230,8 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
|
||||
data-selected={selected ? "true" : "false"}
|
||||
{...props}
|
||||
>
|
||||
{leading ?? <div className="mr-1 w-4 shrink-0" />}
|
||||
<div className={cn("mr-2 flex shrink-0 items-center", !icon && "text-muted-foreground", iconClassName)}>
|
||||
{icon ?? <FileText size={14} />}
|
||||
</div>
|
||||
{leading ?? <div className="mr-1 h-5 w-4 flex-shrink-0" />}
|
||||
{icon ? <div className="mr-2 flex shrink-0 items-center self-center">{icon}</div> : <FileText size={14} className="mr-2 shrink-0 text-muted-foreground" />}
|
||||
{content ?? (
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
{editing && onRenameCommit && onRenameCancel ? (
|
||||
@@ -248,7 +242,7 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
|
||||
/>
|
||||
) : (
|
||||
// leading-5 keeps CJK glyphs inside the line box under truncate overflow.
|
||||
<div className="min-w-0 flex-1 truncate leading-5 translate-y-px" title={label}>{label}</div>
|
||||
<div className="min-w-0 truncate leading-5" title={label}>{label}</div>
|
||||
)}
|
||||
{detail && <div className="truncate text-xs leading-4 text-muted-foreground">{detail}</div>}
|
||||
</div>
|
||||
|
||||
@@ -29,19 +29,19 @@
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
|
||||
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="174.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="255.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="346.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="433.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="523.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="610.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="701.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="177.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="261.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="354.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="444.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="537.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="627.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="720.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.9,461.5 C 93.9,461.4 94.8,461.1 95.8,461.0 C 96.8,460.8 97.8,460.5 98.7,460.4 C 99.7,460.3 100.7,460.4 101.6,460.3 C 102.6,460.3 103.6,460.2 104.5,460.2 C 105.5,460.1 106.5,460.1 107.5,460.0 C 108.4,459.9 108.9,459.9 110.4,459.8 C 111.8,459.7 114.7,459.7 116.2,459.6 C 117.6,459.6 118.1,459.5 119.1,459.5 C 120.1,459.5 121.0,459.4 122.0,459.4 C 123.0,459.4 123.9,459.4 124.9,459.4 C 125.9,459.3 126.9,459.3 127.8,459.3 C 128.8,459.3 129.8,459.2 130.7,459.1 C 131.7,459.0 132.7,459.0 133.6,458.9 C 134.6,458.7 135.6,458.4 136.6,458.0 C 137.5,457.5 138.5,456.7 139.5,456.4 C 140.4,456.1 141.4,456.2 142.4,456.2 C 143.3,456.1 144.3,456.1 145.3,456.0 C 146.2,456.0 146.7,455.9 148.2,455.9 C 149.6,455.9 152.1,455.9 154.0,455.9 C 155.9,455.8 158.4,455.8 159.8,455.7 C 161.3,455.7 161.8,455.6 162.7,455.6 C 163.7,455.6 164.7,455.5 165.6,455.5 C 166.6,455.4 167.6,455.4 168.6,455.3 C 169.5,455.3 170.5,455.3 171.5,455.3 C 172.4,455.3 172.9,455.2 174.4,455.2 C 175.8,455.2 177.8,455.1 180.2,455.1 C 182.6,455.1 186.5,455.0 188.9,455.0 C 191.3,455.0 191.8,455.0 194.7,455.0 C 197.7,454.9 204.0,454.8 206.4,454.7 C 208.8,454.6 207.8,454.7 209.3,454.6 C 210.7,454.6 212.7,454.6 215.1,454.6 C 217.5,454.5 219.0,454.5 223.8,454.4 C 228.7,454.4 240.3,454.3 244.2,454.3 C 248.1,454.2 246.1,454.2 247.1,454.2 C 248.1,454.2 248.6,454.9 250.0,454.1 C 251.5,453.3 254.4,452.6 255.8,449.3 C 257.3,445.9 257.8,436.9 258.8,434.1 C 259.7,431.3 260.7,432.9 261.7,432.5 C 262.6,432.0 263.6,431.8 264.6,431.5 C 265.5,431.2 266.5,430.9 267.5,430.8 C 268.4,430.6 269.4,430.5 270.4,430.4 C 271.4,430.3 272.3,430.1 273.3,430.0 C 274.3,429.9 275.2,430.0 276.2,429.9 C 277.2,429.8 278.1,429.6 279.1,429.5 C 280.1,429.4 281.1,429.4 282.0,429.3 C 283.0,429.1 284.0,428.7 284.9,428.6 C 285.9,428.4 286.9,428.5 287.8,428.4 C 288.8,428.3 289.8,428.0 290.8,428.0 C 291.7,427.9 292.7,427.9 293.7,427.9 C 294.6,427.8 295.6,427.8 296.6,427.7 C 297.5,427.6 298.5,427.6 299.5,427.5 C 300.5,427.4 301.4,427.4 302.4,427.3 C 303.4,427.2 304.3,426.9 305.3,426.7 C 306.3,426.6 307.2,427.4 308.2,426.3 C 309.2,425.2 310.2,421.5 311.1,420.3 C 312.1,419.1 313.1,419.2 314.0,419.0 C 315.0,418.8 316.0,419.1 316.9,419.0 C 317.9,418.8 318.9,418.4 319.8,418.1 C 320.8,417.7 321.8,417.5 322.8,416.9 C 323.7,416.3 324.7,415.2 325.7,414.5 C 326.6,413.8 327.6,413.1 328.6,412.6 C 329.5,412.1 330.5,411.7 331.5,411.4 C 332.5,411.1 333.4,410.9 334.4,410.8 C 335.4,410.6 336.3,410.7 337.3,410.5 C 338.3,410.4 339.2,410.1 340.2,410.0 C 341.2,409.9 342.2,410.0 343.1,409.9 C 344.1,409.8 345.1,409.8 346.0,409.6 C 347.0,409.5 348.0,409.5 348.9,409.1 C 349.9,408.6 350.9,407.3 351.9,406.8 C 352.8,406.2 353.8,406.0 354.8,405.6 C 355.7,405.1 356.7,406.0 357.7,404.1 C 358.6,402.3 359.6,396.7 360.6,394.6 C 361.6,392.4 362.5,392.1 363.5,391.2 C 364.5,390.3 365.4,389.7 366.4,389.1 C 367.4,388.4 368.3,387.8 369.3,387.3 C 370.3,386.8 371.2,386.4 372.2,386.1 C 373.2,385.8 374.2,385.7 375.1,385.4 C 376.1,385.2 377.1,384.9 378.0,384.5 C 379.0,384.2 380.0,383.6 380.9,383.3 C 381.9,383.0 382.9,383.1 383.9,382.8 C 384.8,382.5 385.8,381.9 386.8,381.5 C 387.7,381.2 388.7,380.9 389.7,380.6 C 390.6,380.3 391.6,380.1 392.6,379.9 C 393.6,379.7 394.5,379.7 395.5,379.5 C 396.5,379.3 397.4,379.1 398.4,378.9 C 399.4,378.7 400.3,378.5 401.3,378.3 C 402.3,378.0 403.3,377.8 404.2,377.5 C 405.2,377.1 406.2,376.4 407.1,376.1 C 408.1,375.7 409.1,375.6 410.0,375.4 C 411.0,375.2 412.0,375.1 413.0,375.0 C 413.9,374.8 414.9,374.7 415.9,374.6 C 416.8,374.5 417.8,374.3 418.8,374.2 C 419.7,374.1 420.7,374.2 421.7,374.0 C 422.7,373.9 423.6,373.6 424.6,373.3 C 425.6,372.9 426.5,372.4 427.5,372.0 C 428.5,371.6 429.4,371.1 430.4,370.9 C 431.4,370.6 432.3,370.6 433.3,370.4 C 434.3,370.3 435.3,370.2 436.2,369.9 C 437.2,369.7 438.2,369.0 439.1,368.8 C 440.1,368.5 441.1,368.6 442.0,368.5 C 443.0,368.3 444.0,368.0 445.0,367.8 C 445.9,367.6 446.9,367.4 447.9,367.2 C 448.8,367.0 449.8,366.7 450.8,366.5 C 451.7,366.3 452.7,366.2 453.7,365.9 C 454.7,365.7 455.6,365.2 456.6,365.0 C 457.6,364.7 458.5,364.5 459.5,364.2 C 460.5,364.0 461.4,363.6 462.4,363.3 C 463.4,363.0 464.4,362.6 465.3,362.3 C 466.3,362.0 467.3,362.0 468.2,361.5 C 469.2,361.0 470.2,360.0 471.1,359.5 C 472.1,359.1 473.1,359.0 474.1,358.9 C 475.0,358.7 476.0,358.7 477.0,358.6 C 477.9,358.5 478.9,358.3 479.9,358.2 C 480.8,358.1 481.8,358.3 482.8,357.9 C 483.8,357.4 484.7,356.1 485.7,355.6 C 486.7,355.1 487.6,355.2 488.6,354.9 C 489.6,354.6 490.5,354.1 491.5,353.8 C 492.5,353.6 493.4,353.8 494.4,353.5 C 495.4,353.3 496.4,352.7 497.3,352.4 C 498.3,352.2 499.3,352.2 500.2,352.1 C 501.2,352.0 502.2,352.0 503.1,351.7 C 504.1,351.5 505.1,351.1 506.1,350.5 C 507.0,349.9 508.0,348.7 509.0,348.1 C 509.9,347.5 510.9,347.2 511.9,346.8 C 512.8,346.3 513.8,346.2 514.8,345.4 C 515.8,344.5 516.7,342.5 517.7,341.6 C 518.7,340.6 519.6,340.2 520.6,339.7 C 521.6,339.1 522.5,339.0 523.5,338.1 C 524.5,337.3 525.5,335.4 526.4,334.6 C 527.4,333.7 528.4,333.6 529.3,333.1 C 530.3Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.9,461.5 C 93.9,461.4 94.8,461.1 95.8,461.0 C 96.8,460.8 97.8,460.5 98.7,460.4 C 99.7,460.3 100.7,460.4 101.6,460.3 C 102.6,460.3 103.6,460.2 104.5,460.2 C 105.5,460.1 106.5,460.1 107.5,460.0 C 108.4,459.9 108.9,459.9 110.4,459.8 C 111.8,459.7 114.7,459.7 116.2,459.6 C 117.6,459.6 118.1,459.5 119.1,459.5 C 120.1,459.5 121.0,459.4 122.0,459.4 C 123.0,459.4 123.9,459.4 124.9,459.4 C 125.9,459.3 126.9,459.3 127.8,459.3 C 128.8,459.3 129.8,459.2 130.7,459.1 C 131.7,459.0 132.7,459.0 133.6,458.9 C 134.6,458.7 135.6,458.4 136.6,458.0 C 137.5,457.5 138.5,456.7 139.5,456.4 C 140.4,456.1 141.4,456.2 142.4,456.2 C 143.3,456.1 144.3,456.1 145.3,456.0 C 146.2,456.0 146.7,455.9 148.2,455.9 C 149.6,455.9 152.1,455.9 154.0,455.9 C 155.9,455.8 158.4,455.8 159.8,455.7 C 161.3,455.7 161.8,455.6 162.7,455.6 C 163.7,455.6 164.7,455.5 165.6,455.5 C 166.6,455.4 167.6,455.4 168.6,455.3 C 169.5,455.3 170.5,455.3 171.5,455.3 C 172.4,455.3 172.9,455.2 174.4,455.2 C 175.8,455.2 177.8,455.1 180.2,455.1 C 182.6,455.1 186.5,455.0 188.9,455.0 C 191.3,455.0 191.8,455.0 194.7,455.0 C 197.7,454.9 204.0,454.8 206.4,454.7 C 208.8,454.6 207.8,454.7 209.3,454.6 C 210.7,454.6 212.7,454.6 215.1,454.6 C 217.5,454.5 219.0,454.5 223.8,454.4 C 228.7,454.4 240.3,454.3 244.2,454.3 C 248.1,454.2 246.1,454.2 247.1,454.2 C 248.1,454.2 248.6,454.9 250.0,454.1 C 251.5,453.3 254.4,452.6 255.8,449.3 C 257.3,445.9 257.8,436.9 258.8,434.1 C 259.7,431.3 260.7,432.9 261.7,432.5 C 262.6,432.0 263.6,431.8 264.6,431.5 C 265.5,431.2 266.5,430.9 267.5,430.8 C 268.4,430.6 269.4,430.5 270.4,430.4 C 271.4,430.3 272.3,430.1 273.3,430.0 C 274.3,429.9 275.2,430.0 276.2,429.9 C 277.2,429.8 278.1,429.6 279.1,429.5 C 280.1,429.4 281.1,429.4 282.0,429.3 C 283.0,429.1 284.0,428.7 284.9,428.6 C 285.9,428.4 286.9,428.5 287.8,428.4 C 288.8,428.3 289.8,428.0 290.8,428.0 C 291.7,427.9 292.7,427.9 293.7,427.9 C 294.6,427.8 295.6,427.8 296.6,427.7 C 297.5,427.6 298.5,427.6 299.5,427.5 C 300.5,427.4 301.4,427.4 302.4,427.3 C 303.4,427.2 304.3,426.9 305.3,426.7 C 306.3,426.6 307.2,427.4 308.2,426.3 C 309.2,425.2 310.2,421.5 311.1,420.3 C 312.1,419.1 313.1,419.2 314.0,419.0 C 315.0,418.8 316.0,419.1 316.9,419.0 C 317.9,418.8 318.9,418.4 319.8,418.1 C 320.8,417.7 321.8,417.5 322.8,416.9 C 323.7,416.3 324.7,415.2 325.7,414.5 C 326.6,413.8 327.6,413.1 328.6,412.6 C 329.5,412.1 330.5,411.7 331.5,411.4 C 332.5,411.1 333.4,410.9 334.4,410.8 C 335.4,410.6 336.3,410.7 337.3,410.5 C 338.3,410.4 339.2,410.1 340.2,410.0 C 341.2,409.9 342.2,410.0 343.1,409.9 C 344.1,409.8 345.1,409.8 346.0,409.6 C 347.0,409.5 348.0,409.5 348.9,409.1 C 349.9,408.6 350.9,407.3 351.9,406.8 C 352.8,406.2 353.8,406.0 354.8,405.6 C 355.7,405.1 356.7,406.0 357.7,404.1 C 358.6,402.3 359.6,396.7 360.6,394.6 C 361.6,392.4 362.5,392.1 363.5,391.2 C 364.5,390.3 365.4,389.7 366.4,389.1 C 367.4,388.4 368.3,387.8 369.3,387.3 C 370.3,386.8 371.2,386.4 372.2,386.1 C 373.2,385.8 374.2,385.7 375.1,385.4 C 376.1,385.2 377.1,384.9 378.0,384.5 C 379.0,384.2 380.0,383.6 380.9,383.3 C 381.9,383.0 382.9,383.1 383.9,382.8 C 384.8,382.5 385.8,381.9 386.8,381.5 C 387.7,381.2 388.7,380.9 389.7,380.6 C 390.6,380.3 391.6,380.1 392.6,379.9 C 393.6,379.7 394.5,379.7 395.5,379.5 C 396.5,379.3 397.4,379.1 398.4,378.9 C 399.4,378.7 400.3,378.5 401.3,378.3 C 402.3,378.0 403.3,377.8 404.2,377.5 C 405.2,377.1 406.2,376.4 407.1,376.1 C 408.1,375.7 409.1,375.6 410.0,375.4 C 411.0,375.2 412.0,375.1 413.0,375.0 C 413.9,374.8 414.9,374.7 415.9,374.6 C 416.8,374.5 417.8,374.3 418.8,374.2 C 419.7,374.1 420.7,374.2 421.7,374.0 C 422.7,373.9 423.6,373.6 424.6,373.3 C 425.6,372.9 426.5,372.4 427.5,372.0 C 428.5,371.6 429.4,371.1 430.4,370.9 C 431.4,370.6 432.3,370.6 433.3,370.4 C 434.3,370.3 435.3,370.2 436.2,369.9 C 437.2,369.7 438.2,369.0 439.1,368.8 C 440.1,368.5 441.1,368.6 442.0,368.5 C 443.0,368.3 444.0,368.0 445.0,367.8 C 445.9,367.6 446.9,367.4 447.9,367.2 C 448.8,367.0 449.8,366.7 450.8,366.5 C 451.7,366.3 452.7,366.2 453.7,365.9 C 454.7,365.7 455.6,365.2 456.6,365.0 C 457.6,364.7 458.5,364.5 459.5,364.2 C 460.5,364.0 461.4,363.6 462.4,363.3 C 463.4,363.0 464.4,362.6 465.3,362.3 C 466.3,362.0 467.3,362.0 468.2,361.5 C 469.2,361.0 470.2,360.0 471.1,359.5 C 472.1,359.1 473.1,359.0 474.1,358.9 C 475.0,358.7 476.0,358.7 477.0,358.6 C 477.9,358.5 478.9,358.3 479.9,358.2 C 480.8,358.1 481.8,358.3 482.8,357.9 C 483.8,357.4 484.7,356.1 485.7,355.6 C 486.7,355.1 487.6,355.2 488.6,354.9 C 489.6,354.6 490.5,354.1 491.5,353.8 C 492.5,353.6 493.4,353.8 494.4,353.5 C 495.4,353.3 496.4,352.7 497.3,352.4 C 498.3,352.2 499.3,352.2 500.2,352.1 C 501.2,352.0 502.2,352.0 503.1,351.7 C 504.1,351.5 505.1,351.1 506.1,350.5 C 507.0,349.9 508.0,348.7 509.0,348.1 C 509.9,347.5 510.9,347.2 511.9,346.8 C 512.8,346.3 513.8,346.2 514.8,345.4 C 515.8,344.5 516.7,342.5 517.7,341.6 C 518.7,340.6 519.6,340.2 520.6,339.7 C 521.6,339.1 522.5,339.0 523.5,338.1 C 524.5,337.3 525.5,335.4 526.4,334.6 C 527.4,333.7 528.4,333.6 529.3,333.1 C 530.3Line truncated
|
||||
<circle cx="765.0" cy="121.9" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5343</text>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.5,461.6 93.0,461.5 C 94.0,461.4 95.0,461.1 96.0,461.0 C 97.0,460.8 98.0,460.5 99.0,460.4 C 100.0,460.3 101.0,460.4 102.0,460.3 C 103.0,460.3 104.0,460.2 105.0,460.2 C 106.0,460.1 107.0,460.1 108.0,460.0 C 109.0,459.9 109.5,459.9 111.0,459.8 C 112.5,459.7 115.5,459.7 117.0,459.6 C 118.5,459.6 119.0,459.5 120.0,459.5 C 121.0,459.5 122.0,459.4 123.0,459.4 C 124.0,459.4 125.0,459.4 126.0,459.4 C 127.0,459.3 128.0,459.3 129.0,459.3 C 130.0,459.3 131.0,459.2 132.0,459.1 C 133.0,459.0 134.0,459.0 135.0,458.9 C 136.0,458.7 137.0,458.4 138.0,458.0 C 139.0,457.5 140.0,456.7 141.0,456.4 C 142.0,456.1 143.0,456.2 144.0,456.2 C 145.0,456.1 146.0,456.1 147.0,456.0 C 148.0,456.0 148.5,455.9 150.0,455.9 C 151.5,455.9 154.0,455.9 156.0,455.9 C 158.0,455.8 160.5,455.8 162.0,455.7 C 163.5,455.7 164.0,455.6 165.0,455.6 C 166.0,455.6 167.0,455.5 168.0,455.5 C 169.0,455.4 170.0,455.4 171.0,455.3 C 172.0,455.3 173.0,455.3 174.0,455.3 C 175.0,455.3 175.5,455.2 177.0,455.2 C 178.5,455.2 180.5,455.1 183.0,455.1 C 185.5,455.1 189.5,455.0 192.0,455.0 C 194.5,455.0 195.0,455.0 198.0,455.0 C 201.0,454.9 207.5,454.8 210.0,454.7 C 212.5,454.6 211.5,454.7 213.0,454.6 C 214.5,454.6 216.5,454.6 219.0,454.6 C 221.5,454.5 223.0,454.5 228.0,454.4 C 233.0,454.4 245.0,454.3 249.0,454.3 C 253.0,454.2 251.0,454.2 252.0,454.2 C 253.0,454.2 253.5,454.9 255.0,454.1 C 256.5,453.3 259.5,452.6 261.0,449.3 C 262.5,445.9 263.0,436.9 264.0,434.1 C 265.0,431.3 266.0,432.9 267.0,432.4 C 268.0,432.0 269.0,431.7 270.0,431.4 C 271.0,431.1 272.0,430.9 273.0,430.7 C 274.0,430.5 275.0,430.4 276.0,430.3 C 277.0,430.2 278.0,430.0 279.0,429.9 C 280.0,429.9 281.0,430.0 282.0,429.9 C 283.0,429.8 284.0,429.5 285.0,429.4 C 286.0,429.3 287.0,429.4 288.0,429.2 C 289.0,429.1 290.0,428.7 291.0,428.5 C 292.0,428.4 293.0,428.4 294.0,428.3 C 295.0,428.2 296.0,428.0 297.0,427.9 C 298.0,427.8 299.0,427.9 300.0,427.8 C 301.0,427.8 302.0,427.7 303.0,427.6 C 304.0,427.6 305.0,427.5 306.0,427.4 C 307.0,427.4 308.0,427.4 309.0,427.3 C 310.0,427.1 311.0,426.8 312.0,426.7 C 313.0,426.5 314.0,427.3 315.0,426.2 C 316.0,425.2 317.0,421.4 318.0,420.2 C 319.0,419.0 320.0,419.2 321.0,419.0 C 322.0,418.7 323.0,419.1 324.0,418.9 C 325.0,418.7 326.0,418.3 327.0,418.0 C 328.0,417.7 329.0,417.4 330.0,416.8 C 331.0,416.3 332.0,415.1 333.0,414.4 C 334.0,413.7 335.0,413.0 336.0,412.5 C 337.0,412.0 338.0,411.6 339.0,411.3 C 340.0,411.0 341.0,410.9 342.0,410.7 C 343.0,410.6 344.0,410.6 345.0,410.5 C 346.0,410.3 347.0,410.1 348.0,410.0 C 349.0,409.8 350.0,409.9 351.0,409.8 C 352.0,409.8 353.0,409.7 354.0,409.6 C 355.0,409.4 356.0,409.5 357.0,409.0 C 358.0,408.5 359.0,407.3 360.0,406.7 C 361.0,406.1 362.0,405.9 363.0,405.5 C 364.0,405.0 365.0,405.9 366.0,404.1 C 367.0,402.3 368.0,396.7 369.0,394.5 C 370.0,392.3 371.0,392.0 372.0,391.1 C 373.0,390.1 374.0,389.6 375.0,389.0 C 376.0,388.3 377.0,387.7 378.0,387.2 C 379.0,386.7 380.0,386.3 381.0,386.0 C 382.0,385.6 383.0,385.6 384.0,385.3 C 385.0,385.1 386.0,384.8 387.0,384.4 C 388.0,384.1 389.0,383.5 390.0,383.2 C 391.0,382.9 392.0,382.9 393.0,382.6 C 394.0,382.3 395.0,381.8 396.0,381.4 C 397.0,381.1 398.0,380.7 399.0,380.5 C 400.0,380.2 401.0,379.9 402.0,379.8 C 403.0,379.6 404.0,379.5 405.0,379.4 C 406.0,379.2 407.0,379.0 408.0,378.7 C 409.0,378.5 410.0,378.3 411.0,378.1 C 412.0,377.9 413.0,377.6 414.0,377.3 C 415.0,376.9 416.0,376.2 417.0,375.9 C 418.0,375.5 419.0,375.4 420.0,375.2 C 421.0,375.0 422.0,374.9 423.0,374.8 C 424.0,374.6 425.0,374.5 426.0,374.4 C 427.0,374.3 428.0,374.1 429.0,374.0 C 430.0,373.9 431.0,374.0 432.0,373.8 C 433.0,373.7 434.0,373.4 435.0,373.1 C 436.0,372.7 437.0,372.2 438.0,371.8 C 439.0,371.4 440.0,371.0 441.0,370.7 C 442.0,370.4 443.0,370.4 444.0,370.3 C 445.0,370.1 446.0,370.0 447.0,369.7 C 448.0,369.5 449.0,368.8 450.0,368.6 C 451.0,368.3 452.0,368.4 453.0,368.3 C 454.0,368.1 455.0,367.8 456.0,367.6 C 457.0,367.3 458.0,367.2 459.0,366.9 C 460.0,366.7 461.0,366.4 462.0,366.2 C 463.0,366.0 464.0,365.9 465.0,365.7 C 466.0,365.4 467.0,365.0 468.0,364.7 C 469.0,364.4 470.0,364.3 471.0,364.0 C 472.0,363.7 473.0,363.4 474.0,363.0 C 475.0,362.7 476.0,362.4 477.0,362.1 C 478.0,361.8 479.0,361.7 480.0,361.2 C 481.0,360.8 482.0,359.7 483.0,359.3 C 484.0,358.8 485.0,358.8 486.0,358.6 C 487.0,358.5 488.0,358.5 489.0,358.4 C 490.0,358.3 491.0,358.1 492.0,357.9 C 493.0,357.8 494.0,358.0 495.0,357.6 C 496.0,357.2 497.0,355.9 498.0,355.4 C 499.0,354.9 500.0,355.0 501.0,354.7 C 502.0,354.4 503.0,353.8 504.0,353.6 C 505.0,353.4 506.0,353.5 507.0,353.3 C 508.0,353.0 509.0,352.4 510.0,352.2 C 511.0,352.0 512.0,352.0 513.0,351.9 C 514.0,351.7 515.0,351.7 516.0,351.5 C 517.0,351.2 518.0,350.9 519.0,350.3 C 520.0,349.7 521.0,348.5 522.0,347.8 C 523.0,347.2 524.0,347.0 525.0,346.5 C 526.0,346.0 527.0,346.0 528.0,345.1 C 529.0,344.2 530.0,342.3 531.0,341.3 C 532.0,340.4 533.0,340.0 534.0,339.4 C 535.0,338.8 536.0,338.7 537.0,337.9 C 538.0,337.0 539.0,335.2 540.0,334.3 C 541.0,333.5 542.0,333.3 543.0,332.8 C 544.Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.5,461.6 93.0,461.5 C 94.0,461.4 95.0,461.1 96.0,461.0 C 97.0,460.8 98.0,460.5 99.0,460.4 C 100.0,460.3 101.0,460.4 102.0,460.3 C 103.0,460.3 104.0,460.2 105.0,460.2 C 106.0,460.1 107.0,460.1 108.0,460.0 C 109.0,459.9 109.5,459.9 111.0,459.8 C 112.5,459.7 115.5,459.7 117.0,459.6 C 118.5,459.6 119.0,459.5 120.0,459.5 C 121.0,459.5 122.0,459.4 123.0,459.4 C 124.0,459.4 125.0,459.4 126.0,459.4 C 127.0,459.3 128.0,459.3 129.0,459.3 C 130.0,459.3 131.0,459.2 132.0,459.1 C 133.0,459.0 134.0,459.0 135.0,458.9 C 136.0,458.7 137.0,458.4 138.0,458.0 C 139.0,457.5 140.0,456.7 141.0,456.4 C 142.0,456.1 143.0,456.2 144.0,456.2 C 145.0,456.1 146.0,456.1 147.0,456.0 C 148.0,456.0 148.5,455.9 150.0,455.9 C 151.5,455.9 154.0,455.9 156.0,455.9 C 158.0,455.8 160.5,455.8 162.0,455.7 C 163.5,455.7 164.0,455.6 165.0,455.6 C 166.0,455.6 167.0,455.5 168.0,455.5 C 169.0,455.4 170.0,455.4 171.0,455.3 C 172.0,455.3 173.0,455.3 174.0,455.3 C 175.0,455.3 175.5,455.2 177.0,455.2 C 178.5,455.2 180.5,455.1 183.0,455.1 C 185.5,455.1 189.5,455.0 192.0,455.0 C 194.5,455.0 195.0,455.0 198.0,455.0 C 201.0,454.9 207.5,454.8 210.0,454.7 C 212.5,454.6 211.5,454.7 213.0,454.6 C 214.5,454.6 216.5,454.6 219.0,454.6 C 221.5,454.5 223.0,454.5 228.0,454.4 C 233.0,454.4 245.0,454.3 249.0,454.3 C 253.0,454.2 251.0,454.2 252.0,454.2 C 253.0,454.2 253.5,454.9 255.0,454.1 C 256.5,453.3 259.5,452.6 261.0,449.3 C 262.5,445.9 263.0,436.9 264.0,434.1 C 265.0,431.3 266.0,432.9 267.0,432.4 C 268.0,432.0 269.0,431.7 270.0,431.4 C 271.0,431.1 272.0,430.9 273.0,430.7 C 274.0,430.5 275.0,430.4 276.0,430.3 C 277.0,430.2 278.0,430.0 279.0,429.9 C 280.0,429.9 281.0,430.0 282.0,429.9 C 283.0,429.8 284.0,429.5 285.0,429.4 C 286.0,429.3 287.0,429.4 288.0,429.2 C 289.0,429.1 290.0,428.7 291.0,428.5 C 292.0,428.4 293.0,428.4 294.0,428.3 C 295.0,428.2 296.0,428.0 297.0,427.9 C 298.0,427.8 299.0,427.9 300.0,427.8 C 301.0,427.8 302.0,427.7 303.0,427.6 C 304.0,427.6 305.0,427.5 306.0,427.4 C 307.0,427.4 308.0,427.4 309.0,427.3 C 310.0,427.1 311.0,426.8 312.0,426.7 C 313.0,426.5 314.0,427.3 315.0,426.2 C 316.0,425.2 317.0,421.4 318.0,420.2 C 319.0,419.0 320.0,419.2 321.0,419.0 C 322.0,418.7 323.0,419.1 324.0,418.9 C 325.0,418.7 326.0,418.3 327.0,418.0 C 328.0,417.7 329.0,417.4 330.0,416.8 C 331.0,416.3 332.0,415.1 333.0,414.4 C 334.0,413.7 335.0,413.0 336.0,412.5 C 337.0,412.0 338.0,411.6 339.0,411.3 C 340.0,411.0 341.0,410.9 342.0,410.7 C 343.0,410.6 344.0,410.6 345.0,410.5 C 346.0,410.3 347.0,410.1 348.0,410.0 C 349.0,409.8 350.0,409.9 351.0,409.8 C 352.0,409.8 353.0,409.7 354.0,409.6 C 355.0,409.4 356.0,409.5 357.0,409.0 C 358.0,408.5 359.0,407.3 360.0,406.7 C 361.0,406.1 362.0,405.9 363.0,405.5 C 364.0,405.0 365.0,405.9 366.0,404.1 C 367.0,402.3 368.0,396.7 369.0,394.5 C 370.0,392.3 371.0,392.0 372.0,391.1 C 373.0,390.1 374.0,389.6 375.0,389.0 C 376.0,388.3 377.0,387.7 378.0,387.2 C 379.0,386.7 380.0,386.3 381.0,386.0 C 382.0,385.6 383.0,385.6 384.0,385.3 C 385.0,385.1 386.0,384.8 387.0,384.4 C 388.0,384.1 389.0,383.5 390.0,383.2 C 391.0,382.9 392.0,382.9 393.0,382.6 C 394.0,382.3 395.0,381.8 396.0,381.4 C 397.0,381.1 398.0,380.7 399.0,380.5 C 400.0,380.2 401.0,379.9 402.0,379.8 C 403.0,379.6 404.0,379.5 405.0,379.4 C 406.0,379.2 407.0,379.0 408.0,378.7 C 409.0,378.5 410.0,378.3 411.0,378.1 C 412.0,377.9 413.0,377.6 414.0,377.3 C 415.0,376.9 416.0,376.2 417.0,375.9 C 418.0,375.5 419.0,375.4 420.0,375.2 C 421.0,375.0 422.0,374.9 423.0,374.8 C 424.0,374.6 425.0,374.5 426.0,374.4 C 427.0,374.3 428.0,374.1 429.0,374.0 C 430.0,373.9 431.0,374.0 432.0,373.8 C 433.0,373.7 434.0,373.4 435.0,373.1 C 436.0,372.7 437.0,372.2 438.0,371.8 C 439.0,371.4 440.0,371.0 441.0,370.7 C 442.0,370.4 443.0,370.4 444.0,370.3 C 445.0,370.1 446.0,370.0 447.0,369.7 C 448.0,369.5 449.0,368.8 450.0,368.6 C 451.0,368.3 452.0,368.4 453.0,368.3 C 454.0,368.1 455.0,367.8 456.0,367.6 C 457.0,367.3 458.0,367.2 459.0,366.9 C 460.0,366.7 461.0,366.4 462.0,366.2 C 463.0,366.0 464.0,365.9 465.0,365.7 C 466.0,365.4 467.0,365.0 468.0,364.7 C 469.0,364.4 470.0,364.3 471.0,364.0 C 472.0,363.7 473.0,363.4 474.0,363.0 C 475.0,362.7 476.0,362.4 477.0,362.1 C 478.0,361.8 479.0,361.7 480.0,361.2 C 481.0,360.8 482.0,359.7 483.0,359.3 C 484.0,358.8 485.0,358.8 486.0,358.6 C 487.0,358.5 488.0,358.5 489.0,358.4 C 490.0,358.3 491.0,358.1 492.0,357.9 C 493.0,357.8 494.0,358.0 495.0,357.6 C 496.0,357.2 497.0,355.9 498.0,355.4 C 499.0,354.9 500.0,355.0 501.0,354.7 C 502.0,354.4 503.0,353.8 504.0,353.6 C 505.0,353.4 506.0,353.5 507.0,353.3 C 508.0,353.0 509.0,352.4 510.0,352.2 C 511.0,352.0 512.0,352.0 513.0,351.9 C 514.0,351.7 515.0,351.7 516.0,351.5 C 517.0,351.2 518.0,350.9 519.0,350.3 C 520.0,349.7 521.0,348.5 522.0,347.8 C 523.0,347.2 524.0,347.0 525.0,346.5 C 526.0,346.0 527.0,346.0 528.0,345.1 C 529.0,344.2 530.0,342.3 531.0,341.3 C 532.0,340.4 533.0,340.0 534.0,339.4 C 535.0,338.8 536.0,338.7 537.0,337.9 C 538.0,337.0 539.0,335.2 540.0,334.3 C 541.0,333.5 542.0,333.3 543.0,332.8 C 544.Line truncated
|
||||
<circle cx="765.0" cy="134.6" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5144</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
@@ -29,19 +29,19 @@
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
|
||||
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="174.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="255.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="346.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="433.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="523.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="610.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="701.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="177.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="261.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="354.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="444.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="537.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="627.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="720.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.9,461.5 C 93.9,461.4 94.8,461.1 95.8,461.0 C 96.8,460.8 97.8,460.5 98.7,460.4 C 99.7,460.3 100.7,460.4 101.6,460.3 C 102.6,460.3 103.6,460.2 104.5,460.2 C 105.5,460.1 106.5,460.1 107.5,460.0 C 108.4,459.9 108.9,459.9 110.4,459.8 C 111.8,459.7 114.7,459.7 116.2,459.6 C 117.6,459.6 118.1,459.5 119.1,459.5 C 120.1,459.5 121.0,459.4 122.0,459.4 C 123.0,459.4 123.9,459.4 124.9,459.4 C 125.9,459.3 126.9,459.3 127.8,459.3 C 128.8,459.3 129.8,459.2 130.7,459.1 C 131.7,459.0 132.7,459.0 133.6,458.9 C 134.6,458.7 135.6,458.4 136.6,458.0 C 137.5,457.5 138.5,456.7 139.5,456.4 C 140.4,456.1 141.4,456.2 142.4,456.2 C 143.3,456.1 144.3,456.1 145.3,456.0 C 146.2,456.0 146.7,455.9 148.2,455.9 C 149.6,455.9 152.1,455.9 154.0,455.9 C 155.9,455.8 158.4,455.8 159.8,455.7 C 161.3,455.7 161.8,455.6 162.7,455.6 C 163.7,455.6 164.7,455.5 165.6,455.5 C 166.6,455.4 167.6,455.4 168.6,455.3 C 169.5,455.3 170.5,455.3 171.5,455.3 C 172.4,455.3 172.9,455.2 174.4,455.2 C 175.8,455.2 177.8,455.1 180.2,455.1 C 182.6,455.1 186.5,455.0 188.9,455.0 C 191.3,455.0 191.8,455.0 194.7,455.0 C 197.7,454.9 204.0,454.8 206.4,454.7 C 208.8,454.6 207.8,454.7 209.3,454.6 C 210.7,454.6 212.7,454.6 215.1,454.6 C 217.5,454.5 219.0,454.5 223.8,454.4 C 228.7,454.4 240.3,454.3 244.2,454.3 C 248.1,454.2 246.1,454.2 247.1,454.2 C 248.1,454.2 248.6,454.9 250.0,454.1 C 251.5,453.3 254.4,452.6 255.8,449.3 C 257.3,445.9 257.8,436.9 258.8,434.1 C 259.7,431.3 260.7,432.9 261.7,432.5 C 262.6,432.0 263.6,431.8 264.6,431.5 C 265.5,431.2 266.5,430.9 267.5,430.8 C 268.4,430.6 269.4,430.5 270.4,430.4 C 271.4,430.3 272.3,430.1 273.3,430.0 C 274.3,429.9 275.2,430.0 276.2,429.9 C 277.2,429.8 278.1,429.6 279.1,429.5 C 280.1,429.4 281.1,429.4 282.0,429.3 C 283.0,429.1 284.0,428.7 284.9,428.6 C 285.9,428.4 286.9,428.5 287.8,428.4 C 288.8,428.3 289.8,428.0 290.8,428.0 C 291.7,427.9 292.7,427.9 293.7,427.9 C 294.6,427.8 295.6,427.8 296.6,427.7 C 297.5,427.6 298.5,427.6 299.5,427.5 C 300.5,427.4 301.4,427.4 302.4,427.3 C 303.4,427.2 304.3,426.9 305.3,426.7 C 306.3,426.6 307.2,427.4 308.2,426.3 C 309.2,425.2 310.2,421.5 311.1,420.3 C 312.1,419.1 313.1,419.2 314.0,419.0 C 315.0,418.8 316.0,419.1 316.9,419.0 C 317.9,418.8 318.9,418.4 319.8,418.1 C 320.8,417.7 321.8,417.5 322.8,416.9 C 323.7,416.3 324.7,415.2 325.7,414.5 C 326.6,413.8 327.6,413.1 328.6,412.6 C 329.5,412.1 330.5,411.7 331.5,411.4 C 332.5,411.1 333.4,410.9 334.4,410.8 C 335.4,410.6 336.3,410.7 337.3,410.5 C 338.3,410.4 339.2,410.1 340.2,410.0 C 341.2,409.9 342.2,410.0 343.1,409.9 C 344.1,409.8 345.1,409.8 346.0,409.6 C 347.0,409.5 348.0,409.5 348.9,409.1 C 349.9,408.6 350.9,407.3 351.9,406.8 C 352.8,406.2 353.8,406.0 354.8,405.6 C 355.7,405.1 356.7,406.0 357.7,404.1 C 358.6,402.3 359.6,396.7 360.6,394.6 C 361.6,392.4 362.5,392.1 363.5,391.2 C 364.5,390.3 365.4,389.7 366.4,389.1 C 367.4,388.4 368.3,387.8 369.3,387.3 C 370.3,386.8 371.2,386.4 372.2,386.1 C 373.2,385.8 374.2,385.7 375.1,385.4 C 376.1,385.2 377.1,384.9 378.0,384.5 C 379.0,384.2 380.0,383.6 380.9,383.3 C 381.9,383.0 382.9,383.1 383.9,382.8 C 384.8,382.5 385.8,381.9 386.8,381.5 C 387.7,381.2 388.7,380.9 389.7,380.6 C 390.6,380.3 391.6,380.1 392.6,379.9 C 393.6,379.7 394.5,379.7 395.5,379.5 C 396.5,379.3 397.4,379.1 398.4,378.9 C 399.4,378.7 400.3,378.5 401.3,378.3 C 402.3,378.0 403.3,377.8 404.2,377.5 C 405.2,377.1 406.2,376.4 407.1,376.1 C 408.1,375.7 409.1,375.6 410.0,375.4 C 411.0,375.2 412.0,375.1 413.0,375.0 C 413.9,374.8 414.9,374.7 415.9,374.6 C 416.8,374.5 417.8,374.3 418.8,374.2 C 419.7,374.1 420.7,374.2 421.7,374.0 C 422.7,373.9 423.6,373.6 424.6,373.3 C 425.6,372.9 426.5,372.4 427.5,372.0 C 428.5,371.6 429.4,371.1 430.4,370.9 C 431.4,370.6 432.3,370.6 433.3,370.4 C 434.3,370.3 435.3,370.2 436.2,369.9 C 437.2,369.7 438.2,369.0 439.1,368.8 C 440.1,368.5 441.1,368.6 442.0,368.5 C 443.0,368.3 444.0,368.0 445.0,367.8 C 445.9,367.6 446.9,367.4 447.9,367.2 C 448.8,367.0 449.8,366.7 450.8,366.5 C 451.7,366.3 452.7,366.2 453.7,365.9 C 454.7,365.7 455.6,365.2 456.6,365.0 C 457.6,364.7 458.5,364.5 459.5,364.2 C 460.5,364.0 461.4,363.6 462.4,363.3 C 463.4,363.0 464.4,362.6 465.3,362.3 C 466.3,362.0 467.3,362.0 468.2,361.5 C 469.2,361.0 470.2,360.0 471.1,359.5 C 472.1,359.1 473.1,359.0 474.1,358.9 C 475.0,358.7 476.0,358.7 477.0,358.6 C 477.9,358.5 478.9,358.3 479.9,358.2 C 480.8,358.1 481.8,358.3 482.8,357.9 C 483.8,357.4 484.7,356.1 485.7,355.6 C 486.7,355.1 487.6,355.2 488.6,354.9 C 489.6,354.6 490.5,354.1 491.5,353.8 C 492.5,353.6 493.4,353.8 494.4,353.5 C 495.4,353.3 496.4,352.7 497.3,352.4 C 498.3,352.2 499.3,352.2 500.2,352.1 C 501.2,352.0 502.2,352.0 503.1,351.7 C 504.1,351.5 505.1,351.1 506.1,350.5 C 507.0,349.9 508.0,348.7 509.0,348.1 C 509.9,347.5 510.9,347.2 511.9,346.8 C 512.8,346.3 513.8,346.2 514.8,345.4 C 515.8,344.5 516.7,342.5 517.7,341.6 C 518.7,340.6 519.6,340.2 520.6,339.7 C 521.6,339.1 522.5,339.0 523.5,338.1 C 524.5,337.3 525.5,335.4 526.4,334.6 C 527.4,333.7 528.4,333.6 529.3,333.1 C 530.3Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.9,461.5 C 93.9,461.4 94.8,461.1 95.8,461.0 C 96.8,460.8 97.8,460.5 98.7,460.4 C 99.7,460.3 100.7,460.4 101.6,460.3 C 102.6,460.3 103.6,460.2 104.5,460.2 C 105.5,460.1 106.5,460.1 107.5,460.0 C 108.4,459.9 108.9,459.9 110.4,459.8 C 111.8,459.7 114.7,459.7 116.2,459.6 C 117.6,459.6 118.1,459.5 119.1,459.5 C 120.1,459.5 121.0,459.4 122.0,459.4 C 123.0,459.4 123.9,459.4 124.9,459.4 C 125.9,459.3 126.9,459.3 127.8,459.3 C 128.8,459.3 129.8,459.2 130.7,459.1 C 131.7,459.0 132.7,459.0 133.6,458.9 C 134.6,458.7 135.6,458.4 136.6,458.0 C 137.5,457.5 138.5,456.7 139.5,456.4 C 140.4,456.1 141.4,456.2 142.4,456.2 C 143.3,456.1 144.3,456.1 145.3,456.0 C 146.2,456.0 146.7,455.9 148.2,455.9 C 149.6,455.9 152.1,455.9 154.0,455.9 C 155.9,455.8 158.4,455.8 159.8,455.7 C 161.3,455.7 161.8,455.6 162.7,455.6 C 163.7,455.6 164.7,455.5 165.6,455.5 C 166.6,455.4 167.6,455.4 168.6,455.3 C 169.5,455.3 170.5,455.3 171.5,455.3 C 172.4,455.3 172.9,455.2 174.4,455.2 C 175.8,455.2 177.8,455.1 180.2,455.1 C 182.6,455.1 186.5,455.0 188.9,455.0 C 191.3,455.0 191.8,455.0 194.7,455.0 C 197.7,454.9 204.0,454.8 206.4,454.7 C 208.8,454.6 207.8,454.7 209.3,454.6 C 210.7,454.6 212.7,454.6 215.1,454.6 C 217.5,454.5 219.0,454.5 223.8,454.4 C 228.7,454.4 240.3,454.3 244.2,454.3 C 248.1,454.2 246.1,454.2 247.1,454.2 C 248.1,454.2 248.6,454.9 250.0,454.1 C 251.5,453.3 254.4,452.6 255.8,449.3 C 257.3,445.9 257.8,436.9 258.8,434.1 C 259.7,431.3 260.7,432.9 261.7,432.5 C 262.6,432.0 263.6,431.8 264.6,431.5 C 265.5,431.2 266.5,430.9 267.5,430.8 C 268.4,430.6 269.4,430.5 270.4,430.4 C 271.4,430.3 272.3,430.1 273.3,430.0 C 274.3,429.9 275.2,430.0 276.2,429.9 C 277.2,429.8 278.1,429.6 279.1,429.5 C 280.1,429.4 281.1,429.4 282.0,429.3 C 283.0,429.1 284.0,428.7 284.9,428.6 C 285.9,428.4 286.9,428.5 287.8,428.4 C 288.8,428.3 289.8,428.0 290.8,428.0 C 291.7,427.9 292.7,427.9 293.7,427.9 C 294.6,427.8 295.6,427.8 296.6,427.7 C 297.5,427.6 298.5,427.6 299.5,427.5 C 300.5,427.4 301.4,427.4 302.4,427.3 C 303.4,427.2 304.3,426.9 305.3,426.7 C 306.3,426.6 307.2,427.4 308.2,426.3 C 309.2,425.2 310.2,421.5 311.1,420.3 C 312.1,419.1 313.1,419.2 314.0,419.0 C 315.0,418.8 316.0,419.1 316.9,419.0 C 317.9,418.8 318.9,418.4 319.8,418.1 C 320.8,417.7 321.8,417.5 322.8,416.9 C 323.7,416.3 324.7,415.2 325.7,414.5 C 326.6,413.8 327.6,413.1 328.6,412.6 C 329.5,412.1 330.5,411.7 331.5,411.4 C 332.5,411.1 333.4,410.9 334.4,410.8 C 335.4,410.6 336.3,410.7 337.3,410.5 C 338.3,410.4 339.2,410.1 340.2,410.0 C 341.2,409.9 342.2,410.0 343.1,409.9 C 344.1,409.8 345.1,409.8 346.0,409.6 C 347.0,409.5 348.0,409.5 348.9,409.1 C 349.9,408.6 350.9,407.3 351.9,406.8 C 352.8,406.2 353.8,406.0 354.8,405.6 C 355.7,405.1 356.7,406.0 357.7,404.1 C 358.6,402.3 359.6,396.7 360.6,394.6 C 361.6,392.4 362.5,392.1 363.5,391.2 C 364.5,390.3 365.4,389.7 366.4,389.1 C 367.4,388.4 368.3,387.8 369.3,387.3 C 370.3,386.8 371.2,386.4 372.2,386.1 C 373.2,385.8 374.2,385.7 375.1,385.4 C 376.1,385.2 377.1,384.9 378.0,384.5 C 379.0,384.2 380.0,383.6 380.9,383.3 C 381.9,383.0 382.9,383.1 383.9,382.8 C 384.8,382.5 385.8,381.9 386.8,381.5 C 387.7,381.2 388.7,380.9 389.7,380.6 C 390.6,380.3 391.6,380.1 392.6,379.9 C 393.6,379.7 394.5,379.7 395.5,379.5 C 396.5,379.3 397.4,379.1 398.4,378.9 C 399.4,378.7 400.3,378.5 401.3,378.3 C 402.3,378.0 403.3,377.8 404.2,377.5 C 405.2,377.1 406.2,376.4 407.1,376.1 C 408.1,375.7 409.1,375.6 410.0,375.4 C 411.0,375.2 412.0,375.1 413.0,375.0 C 413.9,374.8 414.9,374.7 415.9,374.6 C 416.8,374.5 417.8,374.3 418.8,374.2 C 419.7,374.1 420.7,374.2 421.7,374.0 C 422.7,373.9 423.6,373.6 424.6,373.3 C 425.6,372.9 426.5,372.4 427.5,372.0 C 428.5,371.6 429.4,371.1 430.4,370.9 C 431.4,370.6 432.3,370.6 433.3,370.4 C 434.3,370.3 435.3,370.2 436.2,369.9 C 437.2,369.7 438.2,369.0 439.1,368.8 C 440.1,368.5 441.1,368.6 442.0,368.5 C 443.0,368.3 444.0,368.0 445.0,367.8 C 445.9,367.6 446.9,367.4 447.9,367.2 C 448.8,367.0 449.8,366.7 450.8,366.5 C 451.7,366.3 452.7,366.2 453.7,365.9 C 454.7,365.7 455.6,365.2 456.6,365.0 C 457.6,364.7 458.5,364.5 459.5,364.2 C 460.5,364.0 461.4,363.6 462.4,363.3 C 463.4,363.0 464.4,362.6 465.3,362.3 C 466.3,362.0 467.3,362.0 468.2,361.5 C 469.2,361.0 470.2,360.0 471.1,359.5 C 472.1,359.1 473.1,359.0 474.1,358.9 C 475.0,358.7 476.0,358.7 477.0,358.6 C 477.9,358.5 478.9,358.3 479.9,358.2 C 480.8,358.1 481.8,358.3 482.8,357.9 C 483.8,357.4 484.7,356.1 485.7,355.6 C 486.7,355.1 487.6,355.2 488.6,354.9 C 489.6,354.6 490.5,354.1 491.5,353.8 C 492.5,353.6 493.4,353.8 494.4,353.5 C 495.4,353.3 496.4,352.7 497.3,352.4 C 498.3,352.2 499.3,352.2 500.2,352.1 C 501.2,352.0 502.2,352.0 503.1,351.7 C 504.1,351.5 505.1,351.1 506.1,350.5 C 507.0,349.9 508.0,348.7 509.0,348.1 C 509.9,347.5 510.9,347.2 511.9,346.8 C 512.8,346.3 513.8,346.2 514.8,345.4 C 515.8,344.5 516.7,342.5 517.7,341.6 C 518.7,340.6 519.6,340.2 520.6,339.7 C 521.6,339.1 522.5,339.0 523.5,338.1 C 524.5,337.3 525.5,335.4 526.4,334.6 C 527.4,333.7 528.4,333.6 529.3,333.1 C 530.3Line truncated
|
||||
<circle cx="765.0" cy="121.9" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5343</text>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.5,461.6 93.0,461.5 C 94.0,461.4 95.0,461.1 96.0,461.0 C 97.0,460.8 98.0,460.5 99.0,460.4 C 100.0,460.3 101.0,460.4 102.0,460.3 C 103.0,460.3 104.0,460.2 105.0,460.2 C 106.0,460.1 107.0,460.1 108.0,460.0 C 109.0,459.9 109.5,459.9 111.0,459.8 C 112.5,459.7 115.5,459.7 117.0,459.6 C 118.5,459.6 119.0,459.5 120.0,459.5 C 121.0,459.5 122.0,459.4 123.0,459.4 C 124.0,459.4 125.0,459.4 126.0,459.4 C 127.0,459.3 128.0,459.3 129.0,459.3 C 130.0,459.3 131.0,459.2 132.0,459.1 C 133.0,459.0 134.0,459.0 135.0,458.9 C 136.0,458.7 137.0,458.4 138.0,458.0 C 139.0,457.5 140.0,456.7 141.0,456.4 C 142.0,456.1 143.0,456.2 144.0,456.2 C 145.0,456.1 146.0,456.1 147.0,456.0 C 148.0,456.0 148.5,455.9 150.0,455.9 C 151.5,455.9 154.0,455.9 156.0,455.9 C 158.0,455.8 160.5,455.8 162.0,455.7 C 163.5,455.7 164.0,455.6 165.0,455.6 C 166.0,455.6 167.0,455.5 168.0,455.5 C 169.0,455.4 170.0,455.4 171.0,455.3 C 172.0,455.3 173.0,455.3 174.0,455.3 C 175.0,455.3 175.5,455.2 177.0,455.2 C 178.5,455.2 180.5,455.1 183.0,455.1 C 185.5,455.1 189.5,455.0 192.0,455.0 C 194.5,455.0 195.0,455.0 198.0,455.0 C 201.0,454.9 207.5,454.8 210.0,454.7 C 212.5,454.6 211.5,454.7 213.0,454.6 C 214.5,454.6 216.5,454.6 219.0,454.6 C 221.5,454.5 223.0,454.5 228.0,454.4 C 233.0,454.4 245.0,454.3 249.0,454.3 C 253.0,454.2 251.0,454.2 252.0,454.2 C 253.0,454.2 253.5,454.9 255.0,454.1 C 256.5,453.3 259.5,452.6 261.0,449.3 C 262.5,445.9 263.0,436.9 264.0,434.1 C 265.0,431.3 266.0,432.9 267.0,432.4 C 268.0,432.0 269.0,431.7 270.0,431.4 C 271.0,431.1 272.0,430.9 273.0,430.7 C 274.0,430.5 275.0,430.4 276.0,430.3 C 277.0,430.2 278.0,430.0 279.0,429.9 C 280.0,429.9 281.0,430.0 282.0,429.9 C 283.0,429.8 284.0,429.5 285.0,429.4 C 286.0,429.3 287.0,429.4 288.0,429.2 C 289.0,429.1 290.0,428.7 291.0,428.5 C 292.0,428.4 293.0,428.4 294.0,428.3 C 295.0,428.2 296.0,428.0 297.0,427.9 C 298.0,427.8 299.0,427.9 300.0,427.8 C 301.0,427.8 302.0,427.7 303.0,427.6 C 304.0,427.6 305.0,427.5 306.0,427.4 C 307.0,427.4 308.0,427.4 309.0,427.3 C 310.0,427.1 311.0,426.8 312.0,426.7 C 313.0,426.5 314.0,427.3 315.0,426.2 C 316.0,425.2 317.0,421.4 318.0,420.2 C 319.0,419.0 320.0,419.2 321.0,419.0 C 322.0,418.7 323.0,419.1 324.0,418.9 C 325.0,418.7 326.0,418.3 327.0,418.0 C 328.0,417.7 329.0,417.4 330.0,416.8 C 331.0,416.3 332.0,415.1 333.0,414.4 C 334.0,413.7 335.0,413.0 336.0,412.5 C 337.0,412.0 338.0,411.6 339.0,411.3 C 340.0,411.0 341.0,410.9 342.0,410.7 C 343.0,410.6 344.0,410.6 345.0,410.5 C 346.0,410.3 347.0,410.1 348.0,410.0 C 349.0,409.8 350.0,409.9 351.0,409.8 C 352.0,409.8 353.0,409.7 354.0,409.6 C 355.0,409.4 356.0,409.5 357.0,409.0 C 358.0,408.5 359.0,407.3 360.0,406.7 C 361.0,406.1 362.0,405.9 363.0,405.5 C 364.0,405.0 365.0,405.9 366.0,404.1 C 367.0,402.3 368.0,396.7 369.0,394.5 C 370.0,392.3 371.0,392.0 372.0,391.1 C 373.0,390.1 374.0,389.6 375.0,389.0 C 376.0,388.3 377.0,387.7 378.0,387.2 C 379.0,386.7 380.0,386.3 381.0,386.0 C 382.0,385.6 383.0,385.6 384.0,385.3 C 385.0,385.1 386.0,384.8 387.0,384.4 C 388.0,384.1 389.0,383.5 390.0,383.2 C 391.0,382.9 392.0,382.9 393.0,382.6 C 394.0,382.3 395.0,381.8 396.0,381.4 C 397.0,381.1 398.0,380.7 399.0,380.5 C 400.0,380.2 401.0,379.9 402.0,379.8 C 403.0,379.6 404.0,379.5 405.0,379.4 C 406.0,379.2 407.0,379.0 408.0,378.7 C 409.0,378.5 410.0,378.3 411.0,378.1 C 412.0,377.9 413.0,377.6 414.0,377.3 C 415.0,376.9 416.0,376.2 417.0,375.9 C 418.0,375.5 419.0,375.4 420.0,375.2 C 421.0,375.0 422.0,374.9 423.0,374.8 C 424.0,374.6 425.0,374.5 426.0,374.4 C 427.0,374.3 428.0,374.1 429.0,374.0 C 430.0,373.9 431.0,374.0 432.0,373.8 C 433.0,373.7 434.0,373.4 435.0,373.1 C 436.0,372.7 437.0,372.2 438.0,371.8 C 439.0,371.4 440.0,371.0 441.0,370.7 C 442.0,370.4 443.0,370.4 444.0,370.3 C 445.0,370.1 446.0,370.0 447.0,369.7 C 448.0,369.5 449.0,368.8 450.0,368.6 C 451.0,368.3 452.0,368.4 453.0,368.3 C 454.0,368.1 455.0,367.8 456.0,367.6 C 457.0,367.3 458.0,367.2 459.0,366.9 C 460.0,366.7 461.0,366.4 462.0,366.2 C 463.0,366.0 464.0,365.9 465.0,365.7 C 466.0,365.4 467.0,365.0 468.0,364.7 C 469.0,364.4 470.0,364.3 471.0,364.0 C 472.0,363.7 473.0,363.4 474.0,363.0 C 475.0,362.7 476.0,362.4 477.0,362.1 C 478.0,361.8 479.0,361.7 480.0,361.2 C 481.0,360.8 482.0,359.7 483.0,359.3 C 484.0,358.8 485.0,358.8 486.0,358.6 C 487.0,358.5 488.0,358.5 489.0,358.4 C 490.0,358.3 491.0,358.1 492.0,357.9 C 493.0,357.8 494.0,358.0 495.0,357.6 C 496.0,357.2 497.0,355.9 498.0,355.4 C 499.0,354.9 500.0,355.0 501.0,354.7 C 502.0,354.4 503.0,353.8 504.0,353.6 C 505.0,353.4 506.0,353.5 507.0,353.3 C 508.0,353.0 509.0,352.4 510.0,352.2 C 511.0,352.0 512.0,352.0 513.0,351.9 C 514.0,351.7 515.0,351.7 516.0,351.5 C 517.0,351.2 518.0,350.9 519.0,350.3 C 520.0,349.7 521.0,348.5 522.0,347.8 C 523.0,347.2 524.0,347.0 525.0,346.5 C 526.0,346.0 527.0,346.0 528.0,345.1 C 529.0,344.2 530.0,342.3 531.0,341.3 C 532.0,340.4 533.0,340.0 534.0,339.4 C 535.0,338.8 536.0,338.7 537.0,337.9 C 538.0,337.0 539.0,335.2 540.0,334.3 C 541.0,333.5 542.0,333.3 543.0,332.8 C 544.Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.5,461.6 93.0,461.5 C 94.0,461.4 95.0,461.1 96.0,461.0 C 97.0,460.8 98.0,460.5 99.0,460.4 C 100.0,460.3 101.0,460.4 102.0,460.3 C 103.0,460.3 104.0,460.2 105.0,460.2 C 106.0,460.1 107.0,460.1 108.0,460.0 C 109.0,459.9 109.5,459.9 111.0,459.8 C 112.5,459.7 115.5,459.7 117.0,459.6 C 118.5,459.6 119.0,459.5 120.0,459.5 C 121.0,459.5 122.0,459.4 123.0,459.4 C 124.0,459.4 125.0,459.4 126.0,459.4 C 127.0,459.3 128.0,459.3 129.0,459.3 C 130.0,459.3 131.0,459.2 132.0,459.1 C 133.0,459.0 134.0,459.0 135.0,458.9 C 136.0,458.7 137.0,458.4 138.0,458.0 C 139.0,457.5 140.0,456.7 141.0,456.4 C 142.0,456.1 143.0,456.2 144.0,456.2 C 145.0,456.1 146.0,456.1 147.0,456.0 C 148.0,456.0 148.5,455.9 150.0,455.9 C 151.5,455.9 154.0,455.9 156.0,455.9 C 158.0,455.8 160.5,455.8 162.0,455.7 C 163.5,455.7 164.0,455.6 165.0,455.6 C 166.0,455.6 167.0,455.5 168.0,455.5 C 169.0,455.4 170.0,455.4 171.0,455.3 C 172.0,455.3 173.0,455.3 174.0,455.3 C 175.0,455.3 175.5,455.2 177.0,455.2 C 178.5,455.2 180.5,455.1 183.0,455.1 C 185.5,455.1 189.5,455.0 192.0,455.0 C 194.5,455.0 195.0,455.0 198.0,455.0 C 201.0,454.9 207.5,454.8 210.0,454.7 C 212.5,454.6 211.5,454.7 213.0,454.6 C 214.5,454.6 216.5,454.6 219.0,454.6 C 221.5,454.5 223.0,454.5 228.0,454.4 C 233.0,454.4 245.0,454.3 249.0,454.3 C 253.0,454.2 251.0,454.2 252.0,454.2 C 253.0,454.2 253.5,454.9 255.0,454.1 C 256.5,453.3 259.5,452.6 261.0,449.3 C 262.5,445.9 263.0,436.9 264.0,434.1 C 265.0,431.3 266.0,432.9 267.0,432.4 C 268.0,432.0 269.0,431.7 270.0,431.4 C 271.0,431.1 272.0,430.9 273.0,430.7 C 274.0,430.5 275.0,430.4 276.0,430.3 C 277.0,430.2 278.0,430.0 279.0,429.9 C 280.0,429.9 281.0,430.0 282.0,429.9 C 283.0,429.8 284.0,429.5 285.0,429.4 C 286.0,429.3 287.0,429.4 288.0,429.2 C 289.0,429.1 290.0,428.7 291.0,428.5 C 292.0,428.4 293.0,428.4 294.0,428.3 C 295.0,428.2 296.0,428.0 297.0,427.9 C 298.0,427.8 299.0,427.9 300.0,427.8 C 301.0,427.8 302.0,427.7 303.0,427.6 C 304.0,427.6 305.0,427.5 306.0,427.4 C 307.0,427.4 308.0,427.4 309.0,427.3 C 310.0,427.1 311.0,426.8 312.0,426.7 C 313.0,426.5 314.0,427.3 315.0,426.2 C 316.0,425.2 317.0,421.4 318.0,420.2 C 319.0,419.0 320.0,419.2 321.0,419.0 C 322.0,418.7 323.0,419.1 324.0,418.9 C 325.0,418.7 326.0,418.3 327.0,418.0 C 328.0,417.7 329.0,417.4 330.0,416.8 C 331.0,416.3 332.0,415.1 333.0,414.4 C 334.0,413.7 335.0,413.0 336.0,412.5 C 337.0,412.0 338.0,411.6 339.0,411.3 C 340.0,411.0 341.0,410.9 342.0,410.7 C 343.0,410.6 344.0,410.6 345.0,410.5 C 346.0,410.3 347.0,410.1 348.0,410.0 C 349.0,409.8 350.0,409.9 351.0,409.8 C 352.0,409.8 353.0,409.7 354.0,409.6 C 355.0,409.4 356.0,409.5 357.0,409.0 C 358.0,408.5 359.0,407.3 360.0,406.7 C 361.0,406.1 362.0,405.9 363.0,405.5 C 364.0,405.0 365.0,405.9 366.0,404.1 C 367.0,402.3 368.0,396.7 369.0,394.5 C 370.0,392.3 371.0,392.0 372.0,391.1 C 373.0,390.1 374.0,389.6 375.0,389.0 C 376.0,388.3 377.0,387.7 378.0,387.2 C 379.0,386.7 380.0,386.3 381.0,386.0 C 382.0,385.6 383.0,385.6 384.0,385.3 C 385.0,385.1 386.0,384.8 387.0,384.4 C 388.0,384.1 389.0,383.5 390.0,383.2 C 391.0,382.9 392.0,382.9 393.0,382.6 C 394.0,382.3 395.0,381.8 396.0,381.4 C 397.0,381.1 398.0,380.7 399.0,380.5 C 400.0,380.2 401.0,379.9 402.0,379.8 C 403.0,379.6 404.0,379.5 405.0,379.4 C 406.0,379.2 407.0,379.0 408.0,378.7 C 409.0,378.5 410.0,378.3 411.0,378.1 C 412.0,377.9 413.0,377.6 414.0,377.3 C 415.0,376.9 416.0,376.2 417.0,375.9 C 418.0,375.5 419.0,375.4 420.0,375.2 C 421.0,375.0 422.0,374.9 423.0,374.8 C 424.0,374.6 425.0,374.5 426.0,374.4 C 427.0,374.3 428.0,374.1 429.0,374.0 C 430.0,373.9 431.0,374.0 432.0,373.8 C 433.0,373.7 434.0,373.4 435.0,373.1 C 436.0,372.7 437.0,372.2 438.0,371.8 C 439.0,371.4 440.0,371.0 441.0,370.7 C 442.0,370.4 443.0,370.4 444.0,370.3 C 445.0,370.1 446.0,370.0 447.0,369.7 C 448.0,369.5 449.0,368.8 450.0,368.6 C 451.0,368.3 452.0,368.4 453.0,368.3 C 454.0,368.1 455.0,367.8 456.0,367.6 C 457.0,367.3 458.0,367.2 459.0,366.9 C 460.0,366.7 461.0,366.4 462.0,366.2 C 463.0,366.0 464.0,365.9 465.0,365.7 C 466.0,365.4 467.0,365.0 468.0,364.7 C 469.0,364.4 470.0,364.3 471.0,364.0 C 472.0,363.7 473.0,363.4 474.0,363.0 C 475.0,362.7 476.0,362.4 477.0,362.1 C 478.0,361.8 479.0,361.7 480.0,361.2 C 481.0,360.8 482.0,359.7 483.0,359.3 C 484.0,358.8 485.0,358.8 486.0,358.6 C 487.0,358.5 488.0,358.5 489.0,358.4 C 490.0,358.3 491.0,358.1 492.0,357.9 C 493.0,357.8 494.0,358.0 495.0,357.6 C 496.0,357.2 497.0,355.9 498.0,355.4 C 499.0,354.9 500.0,355.0 501.0,354.7 C 502.0,354.4 503.0,353.8 504.0,353.6 C 505.0,353.4 506.0,353.5 507.0,353.3 C 508.0,353.0 509.0,352.4 510.0,352.2 C 511.0,352.0 512.0,352.0 513.0,351.9 C 514.0,351.7 515.0,351.7 516.0,351.5 C 517.0,351.2 518.0,350.9 519.0,350.3 C 520.0,349.7 521.0,348.5 522.0,347.8 C 523.0,347.2 524.0,347.0 525.0,346.5 C 526.0,346.0 527.0,346.0 528.0,345.1 C 529.0,344.2 530.0,342.3 531.0,341.3 C 532.0,340.4 533.0,340.0 534.0,339.4 C 535.0,338.8 536.0,338.7 537.0,337.9 C 538.0,337.0 539.0,335.2 540.0,334.3 C 541.0,333.5 542.0,333.3 543.0,332.8 C 544.Line truncated
|
||||
<circle cx="765.0" cy="134.6" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5144</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
@@ -380,7 +380,6 @@ export interface VaultNote {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
order?: number;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
resolveNoteFontFamily,
|
||||
resolveNoteFontSelectionFamily,
|
||||
resolveNoteFontSelectionId,
|
||||
} from "./noteFonts.ts";
|
||||
|
||||
const fonts = [
|
||||
{ id: "menlo", family: "Menlo, monospace" },
|
||||
{ id: "fira", family: '"Fira Code", monospace' },
|
||||
];
|
||||
|
||||
test("note font settings store CSS families and read legacy ids", () => {
|
||||
assert.equal(resolveNoteFontSelectionFamily(fonts, "fira"), '"Fira Code", monospace');
|
||||
assert.equal(resolveNoteFontSelectionId(fonts, '"Fira Code", monospace'), "fira");
|
||||
assert.equal(resolveNoteFontFamily(fonts, "menlo"), "Menlo, monospace");
|
||||
assert.equal(resolveNoteFontFamily(fonts, "unknown"), "");
|
||||
assert.equal(resolveNoteFontSelectionId(fonts, ""), "");
|
||||
assert.equal(resolveNoteFontSelectionFamily([{ id: "", family: "" }, ...fonts], ""), "");
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
export interface NoteFontOption {
|
||||
id: string;
|
||||
family: string;
|
||||
}
|
||||
|
||||
export const resolveNoteFontFamily = (
|
||||
fonts: NoteFontOption[],
|
||||
storedValue: string,
|
||||
): string => {
|
||||
if (!storedValue) return "";
|
||||
return fonts.find((font) => font.family === storedValue)?.family
|
||||
?? fonts.find((font) => font.id === storedValue)?.family
|
||||
?? "";
|
||||
};
|
||||
|
||||
export const resolveNoteFontSelectionId = (
|
||||
fonts: NoteFontOption[],
|
||||
storedValue: string,
|
||||
): string => fonts.find((font) => font.family === storedValue)?.id
|
||||
?? fonts.find((font) => font.id === storedValue)?.id
|
||||
?? "";
|
||||
|
||||
export const resolveNoteFontSelectionFamily = (
|
||||
fonts: NoteFontOption[],
|
||||
selectedId: string,
|
||||
): string => fonts.find((font) => font.id === selectedId)?.family ?? "";
|
||||
+1
-343
@@ -3,14 +3,7 @@ import test from "node:test";
|
||||
import {
|
||||
buildVaultNoteMarkdownExportFiles,
|
||||
buildVaultNoteFromMarkdownImport,
|
||||
calculateNoteStats,
|
||||
deriveNoteImportTitle,
|
||||
extractAllNoteTags,
|
||||
extractNoteHeadings,
|
||||
extractNoteSnippet,
|
||||
filterAndSortVaultNotes,
|
||||
formatMarkdownListSelection,
|
||||
formatMarkdownQuoteSelection,
|
||||
getVaultNotesForExportScope,
|
||||
importMarkdownFilesToVaultNotes,
|
||||
importMarkdownPayloadsToVaultNotes,
|
||||
@@ -23,8 +16,7 @@ import {
|
||||
sanitizeNoteExportFileNamePart,
|
||||
sanitizeNoteTitle,
|
||||
sanitizeVaultNote,
|
||||
wrapMarkdownSyntax,
|
||||
} from "./notes.ts";
|
||||
} from "./notes";
|
||||
|
||||
test("sanitizeVaultNote supplies safe defaults", () => {
|
||||
const note = sanitizeVaultNote({ title: " ", content: 123 as never });
|
||||
@@ -127,9 +119,6 @@ test("sanitizeNoteExportFileNamePart removes unsafe path characters", () => {
|
||||
assert.equal(sanitizeNoteExportFileNamePart("a\u0001b", "note"), "a-b");
|
||||
assert.equal(sanitizeNoteExportFileNamePart("..", "note"), "note");
|
||||
assert.equal(sanitizeNoteExportFileNamePart("CON", "note"), "CON_");
|
||||
assert.equal(sanitizeNoteExportFileNamePart("CON.txt", "note"), "CON_.txt");
|
||||
assert.equal(sanitizeNoteExportFileNamePart("com1.backup.md", "note"), "com1_.backup.md");
|
||||
assert.equal(sanitizeNoteExportFileNamePart("release.CON", "note"), "release.CON");
|
||||
});
|
||||
|
||||
test("getVaultNotesForExportScope includes only selected folder descendants", () => {
|
||||
@@ -163,34 +152,6 @@ test("buildVaultNoteMarkdownExportFiles preserves groups and de-duplicates file
|
||||
assert.equal(buildVaultNoteMarkdownExportFiles(notes)[2].name, "note-3.md");
|
||||
});
|
||||
|
||||
test("buildVaultNoteMarkdownExportFiles keeps file names distinct from directory paths", () => {
|
||||
const notes = [
|
||||
sanitizeVaultNote({ id: "n1", title: "docs", content: "root", createdAt: 1, updatedAt: 1, order: 1000 }),
|
||||
sanitizeVaultNote({ id: "n2", title: "child", content: "nested", group: "DOCS.md", createdAt: 1, updatedAt: 1, order: 2000 }),
|
||||
];
|
||||
|
||||
assert.deepEqual(buildVaultNoteMarkdownExportFiles(notes), [
|
||||
{ name: "docs-2.md", content: "root" },
|
||||
{ name: "DOCS.md/child.md", content: "nested" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildVaultNoteMarkdownExportFiles keeps sanitized group paths distinct", () => {
|
||||
const notes = [
|
||||
sanitizeVaultNote({ id: "n1", title: "US", content: "one", group: "Prod:US/Apps", createdAt: 1, updatedAt: 1, order: 1000 }),
|
||||
sanitizeVaultNote({ id: "n2", title: "US", content: "two", group: "Prod?US/Apps", createdAt: 1, updatedAt: 1, order: 2000 }),
|
||||
sanitizeVaultNote({ id: "n3", title: "EU", content: "three", group: "Prod:US/Apps?EU", createdAt: 1, updatedAt: 1, order: 3000 }),
|
||||
sanitizeVaultNote({ id: "n4", title: "EU", content: "four", group: "Prod:US/Apps:EU", createdAt: 1, updatedAt: 1, order: 4000 }),
|
||||
];
|
||||
|
||||
assert.deepEqual(buildVaultNoteMarkdownExportFiles(notes), [
|
||||
{ name: "Prod-US/Apps/US.md", content: "one" },
|
||||
{ name: "Prod-US-2/Apps/US.md", content: "two" },
|
||||
{ name: "Prod-US/Apps-EU/EU.md", content: "three" },
|
||||
{ name: "Prod-US/Apps-EU-2/EU.md", content: "four" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolveRenderedMarkdownLinkHref recovers ssh links sanitized by the editor DOM", () => {
|
||||
assert.equal(
|
||||
resolveRenderedMarkdownLinkHref(
|
||||
@@ -241,22 +202,10 @@ test("deriveNoteImportTitle prefers the first markdown heading", () => {
|
||||
deriveNoteImportTitle("script.md", "```sh\n# not a title\n```\n\n# Real Title"),
|
||||
"Real Title",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNoteImportTitle("tilde.md", "~~~md\n# not a title\n~~~\n\n# Tilde Fence Title"),
|
||||
"Tilde Fence Title",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNoteImportTitle("long-fence.md", "````md\n# not a title\n```\nstill fenced\n````\n\n# Long Fence Title"),
|
||||
"Long Fence Title",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNoteImportTitle("unclosed.md", "```sh\n# not a title\n\n# Also inside fence"),
|
||||
"unclosed",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNoteImportTitle("unclosed-tilde.md", "~~~sh\n# not a title\n\n# Also inside fence"),
|
||||
"unclosed-tilde",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNoteImportTitle("preface.md", "# Real Title\n\n```sh\n# not a title"),
|
||||
"Real Title",
|
||||
@@ -326,294 +275,3 @@ test("importMarkdownFilesToVaultNotes appends notes and skips unsupported files"
|
||||
assert.equal(result.notes[1].title, "Imported");
|
||||
assert.equal(result.notes[1].group, "Ops");
|
||||
});
|
||||
|
||||
test("extractNoteHeadings extracts markdown headings ignoring code blocks", () => {
|
||||
const content = `# Title 1
|
||||
Some intro text
|
||||
\`\`\`ts
|
||||
# Not a heading
|
||||
\`\`\`
|
||||
## Subtitle 1.1
|
||||
### Deep section
|
||||
`;
|
||||
const headings = extractNoteHeadings(content);
|
||||
assert.equal(headings.length, 3);
|
||||
assert.equal(headings[0].text, "Title 1");
|
||||
assert.equal(headings[0].level, 1);
|
||||
assert.equal(headings[1].text, "Subtitle 1.1");
|
||||
assert.equal(headings[1].level, 2);
|
||||
assert.equal(headings[2].text, "Deep section");
|
||||
assert.equal(headings[2].level, 3);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings follows rendered markdown fence and setext rules", () => {
|
||||
const content = `Setext title
|
||||
============
|
||||
|
||||
~~~md
|
||||
# Hidden in tilde fence
|
||||
~~~
|
||||
|
||||
\`\`\`\`md
|
||||
## Hidden in long fence
|
||||
\`\`\`
|
||||
still hidden
|
||||
\`\`\`\`
|
||||
|
||||
# Indented code
|
||||
### Visible ###`;
|
||||
const headings = extractNoteHeadings(content);
|
||||
|
||||
assert.deepEqual(
|
||||
headings.map(({ level, text, line }) => ({ level, text, line })),
|
||||
[
|
||||
{ level: 1, text: "Setext title", line: 1 },
|
||||
{ level: 3, text: "Visible", line: 15 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings does not turn thematic breaks after block headings into setext headings", () => {
|
||||
assert.deepEqual(
|
||||
extractNoteHeadings("# First\n---\n## Second").map(({ level, text }) => ({ level, text })),
|
||||
[
|
||||
{ level: 1, text: "First" },
|
||||
{ level: 2, text: "Second" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings excludes reference definitions before thematic breaks", () => {
|
||||
assert.deepEqual(
|
||||
extractNoteHeadings("[docs]: https://example.com\n---\n## Real").map(({ text }) => text),
|
||||
["Real"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings uses the same visible text as formatted rendered headings", () => {
|
||||
assert.deepEqual(
|
||||
extractNoteHeadings("## **Bold**\n## ~~Removed~~ title\n## [Docs](https://example.com)\n## `code` © more\n## <https://example.com>\n## [Reference][docs]\n\n[docs]: /url")
|
||||
.map(({ text }) => text),
|
||||
["Bold", "Removed title", "Docs", "code © more", "https://example.com", "Reference"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings includes headings rendered inside markdown containers", () => {
|
||||
assert.deepEqual(
|
||||
extractNoteHeadings("> ## Repeat\n## Repeat\n- ### Listed").map(({ level, text }) => ({ level, text })),
|
||||
[
|
||||
{ level: 2, text: "Repeat" },
|
||||
{ level: 2, text: "Repeat" },
|
||||
{ level: 3, text: "Listed" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings keeps complete multi-line setext heading text", () => {
|
||||
const [heading] = extractNoteHeadings("first line\nsecond line\n---");
|
||||
assert.deepEqual(
|
||||
{ level: heading.level, text: heading.text, line: heading.line },
|
||||
{ level: 2, text: "first line second line", line: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteHeadings ignores multi-line definitions and raw HTML blocks", () => {
|
||||
assert.deepEqual(
|
||||
extractNoteHeadings('[foo]: /url\n "title"\n---\n\n<div>\ntext\n---\n</div>\n\n## Real').map(({ text }) => text),
|
||||
["Real"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractNoteSnippet extracts clean plain text from markdown", () => {
|
||||
const content = `# Title
|
||||
Here is **bold** text and [a link](https://example.com) and \`code\`.
|
||||
- [x] Task 1
|
||||
> quote text
|
||||
`;
|
||||
const snippet = extractNoteSnippet(content, 100);
|
||||
assert.ok(!snippet.includes("#"));
|
||||
assert.ok(!snippet.includes("[a link]"));
|
||||
assert.ok(snippet.includes("bold"));
|
||||
assert.ok(snippet.includes("Task 1"));
|
||||
});
|
||||
|
||||
test("extractAllNoteTags aggregates and sorts tags by frequency", () => {
|
||||
const notes = [
|
||||
sanitizeVaultNote({ tags: ["dev", "prod"] }),
|
||||
sanitizeVaultNote({ tags: ["dev", "staging"] }),
|
||||
sanitizeVaultNote({ tags: ["dev"] }),
|
||||
];
|
||||
const tags = extractAllNoteTags(notes);
|
||||
assert.equal(tags[0].tag, "dev");
|
||||
assert.equal(tags[0].count, 3);
|
||||
});
|
||||
|
||||
test("filterAndSortVaultNotes filters by pinned, tags, group and sorts accordingly", () => {
|
||||
const notes = [
|
||||
sanitizeVaultNote({ id: "1", title: "B Note", isPinned: false, updatedAt: 100, tags: ["ssh"] }),
|
||||
sanitizeVaultNote({ id: "2", title: "A Note", isPinned: true, updatedAt: 50, tags: ["ssh"] }),
|
||||
sanitizeVaultNote({ id: "3", title: "C Note", isPinned: false, updatedAt: 200, group: "Ops" }),
|
||||
];
|
||||
|
||||
// Pinned first
|
||||
const sorted = filterAndSortVaultNotes(notes, { sort: "updatedDesc" });
|
||||
assert.equal(sorted[0].id, "2"); // Pinned note first
|
||||
assert.equal(sorted[1].id, "3"); // 200 updatedAt
|
||||
assert.equal(sorted[2].id, "1"); // 100 updatedAt
|
||||
|
||||
// Filter pinned only
|
||||
const pinnedOnly = filterAndSortVaultNotes(notes, { filterMode: "pinned" });
|
||||
assert.equal(pinnedOnly.length, 1);
|
||||
assert.equal(pinnedOnly[0].id, "2");
|
||||
|
||||
// Filter by tag
|
||||
const tagFiltered = filterAndSortVaultNotes(notes, { tag: "ssh" });
|
||||
assert.equal(tagFiltered.length, 2);
|
||||
});
|
||||
|
||||
test("calculateNoteStats calculates lines, chars, and word counts", () => {
|
||||
const content = "Hello world\n这是中文测试内容\nThird line";
|
||||
const stats = calculateNoteStats(content);
|
||||
assert.equal(stats.lines, 3);
|
||||
assert.equal(stats.chars, content.length);
|
||||
// Hello(1) + world(1) + 8 CJK chars + Third(1) + line(1) = 12
|
||||
assert.equal(stats.words, 12);
|
||||
});
|
||||
|
||||
test("wrapMarkdownSyntax wraps or inserts markdown syntax correctly", () => {
|
||||
const base = "Hello world";
|
||||
// bold on 'world' (start 6, end 11)
|
||||
const boldRes = wrapMarkdownSyntax(base, 6, 11, "bold");
|
||||
assert.equal(boldRes.text, "Hello **world**");
|
||||
|
||||
// insert table when no selection (start 0, end 0)
|
||||
const tableRes = wrapMarkdownSyntax("", 0, 0, "table");
|
||||
assert.ok(tableRes.text.includes("| Column 1 | Column 2 | Column 3 |"));
|
||||
|
||||
// insert code block
|
||||
const codeRes = wrapMarkdownSyntax("", 0, 0, "codeblock");
|
||||
assert.ok(codeRes.text.includes("```bash"));
|
||||
assert.equal(codeRes.selectionStart, "\n```bash\n".length);
|
||||
assert.equal(codeRes.selectionEnd, "\n```bash\n".length);
|
||||
|
||||
// insert math block
|
||||
const mathRes = wrapMarkdownSyntax("", 0, 0, "math");
|
||||
assert.ok(mathRes.text.includes("```latex"));
|
||||
assert.equal(mathRes.selectionStart, "\n```latex\n".length);
|
||||
assert.equal(mathRes.selectionEnd, "\n```latex\n".length);
|
||||
|
||||
const selectedCodeRes = wrapMarkdownSyntax("echo ok", 0, 7, "codeblock");
|
||||
assert.equal(
|
||||
selectedCodeRes.text.slice(selectedCodeRes.selectionStart, selectedCodeRes.selectionEnd),
|
||||
"echo ok",
|
||||
);
|
||||
|
||||
const selectedMathRes = wrapMarkdownSyntax("x^2", 0, 3, "math");
|
||||
assert.equal(
|
||||
selectedMathRes.text.slice(selectedMathRes.selectionStart, selectedMathRes.selectionEnd),
|
||||
"x^2",
|
||||
);
|
||||
|
||||
const bulletRes = wrapMarkdownSyntax("one\ntwo", 0, 7, "bullet");
|
||||
assert.equal(bulletRes.text, "\n- one\n- two\n");
|
||||
assert.equal(
|
||||
bulletRes.text.slice(bulletRes.selectionStart, bulletRes.selectionEnd),
|
||||
"one\n- two",
|
||||
);
|
||||
|
||||
const numberRes = wrapMarkdownSyntax("one\ntwo", 0, 7, "number");
|
||||
assert.equal(numberRes.text, "\n1. one\n2. two\n");
|
||||
assert.equal(
|
||||
numberRes.text.slice(numberRes.selectionStart, numberRes.selectionEnd),
|
||||
"one\n2. two",
|
||||
);
|
||||
|
||||
const taskRes = wrapMarkdownSyntax("one\ntwo", 0, 7, "task");
|
||||
assert.equal(taskRes.text, "\n- [ ] one\n- [ ] two\n");
|
||||
assert.equal(
|
||||
taskRes.text.slice(taskRes.selectionStart, taskRes.selectionEnd),
|
||||
"one\n- [ ] two",
|
||||
);
|
||||
|
||||
const quoteRes = wrapMarkdownSyntax("one\n\ntwo", 0, 8, "quote");
|
||||
assert.equal(quoteRes.text, "\n> one\n>\n> two\n");
|
||||
assert.equal(
|
||||
quoteRes.text.slice(quoteRes.selectionStart, quoteRes.selectionEnd),
|
||||
"one\n>\n> two",
|
||||
);
|
||||
|
||||
const trailingNewlineQuoteRes = wrapMarkdownSyntax("one\ntwo\nthree", 0, 8, "quote");
|
||||
assert.equal(trailingNewlineQuoteRes.text, "\n> one\n> two\n>\nthree");
|
||||
assert.equal(
|
||||
trailingNewlineQuoteRes.text.slice(
|
||||
trailingNewlineQuoteRes.selectionStart,
|
||||
trailingNewlineQuoteRes.selectionEnd,
|
||||
),
|
||||
"one\n> two",
|
||||
);
|
||||
|
||||
const leadingNewlineQuoteRes = wrapMarkdownSyntax("\none", 0, 4, "quote");
|
||||
assert.equal(leadingNewlineQuoteRes.text, "\n>\n> one\n");
|
||||
assert.equal(
|
||||
leadingNewlineQuoteRes.text.slice(
|
||||
leadingNewlineQuoteRes.selectionStart,
|
||||
leadingNewlineQuoteRes.selectionEnd,
|
||||
),
|
||||
"one",
|
||||
);
|
||||
|
||||
const newlineOnlyQuoteRes = wrapMarkdownSyntax("\n", 0, 1, "quote");
|
||||
assert.equal(newlineOnlyQuoteRes.text, "\n> Quote\n");
|
||||
assert.equal(
|
||||
newlineOnlyQuoteRes.text.slice(newlineOnlyQuoteRes.selectionStart, newlineOnlyQuoteRes.selectionEnd),
|
||||
"Quote",
|
||||
);
|
||||
|
||||
const trailingNewlineBulletRes = wrapMarkdownSyntax("one\ntwo\nthree", 0, 8, "bullet");
|
||||
assert.equal(trailingNewlineBulletRes.text, "\n- one\n- two\n\nthree");
|
||||
assert.equal(
|
||||
trailingNewlineBulletRes.text.slice(
|
||||
trailingNewlineBulletRes.selectionStart,
|
||||
trailingNewlineBulletRes.selectionEnd,
|
||||
),
|
||||
"one\n- two",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
wrapMarkdownSyntax("one\ntwo\nthree", 0, 8, "number").text,
|
||||
/^3\.\s*$/m,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
wrapMarkdownSyntax("one\ntwo\nthree", 0, 8, "task").text,
|
||||
/^- \[ \]\s*$/m,
|
||||
);
|
||||
|
||||
const blankLinesNumberRes = wrapMarkdownSyntax("one\n\ntwo\n\n\nthree", 0, 11, "number");
|
||||
assert.equal(blankLinesNumberRes.text, "\n1. one\n\n2. two\n\n\n\nthree");
|
||||
assert.equal(
|
||||
blankLinesNumberRes.text.slice(
|
||||
blankLinesNumberRes.selectionStart,
|
||||
blankLinesNumberRes.selectionEnd,
|
||||
),
|
||||
"one\n\n2. two",
|
||||
);
|
||||
|
||||
for (const action of ["bullet", "number", "task"] as const) {
|
||||
const newlineOnlyRes = wrapMarkdownSyntax("\n", 0, 1, action);
|
||||
const expected = action === "bullet" ? "- List item" : action === "number" ? "1. List item" : "- [ ] Task";
|
||||
assert.equal(newlineOnlyRes.text, `\n${expected}\n`);
|
||||
assert.equal(
|
||||
newlineOnlyRes.text.slice(newlineOnlyRes.selectionStart, newlineOnlyRes.selectionEnd),
|
||||
action === "task" ? "Task" : "List item",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("formatMarkdownListSelection prefixes every non-empty selected line", () => {
|
||||
assert.equal(formatMarkdownListSelection("one\ntwo", "bullet"), "- one\n- two");
|
||||
assert.equal(formatMarkdownListSelection("one\n\ntwo", "number"), "1. one\n\n2. two");
|
||||
assert.equal(formatMarkdownListSelection("one\ntwo", "task"), "- [ ] one\n- [ ] two");
|
||||
});
|
||||
|
||||
test("formatMarkdownQuoteSelection prefixes every selected line", () => {
|
||||
assert.equal(formatMarkdownQuoteSelection("one\n\ntwo"), "> one\n>\n> two");
|
||||
});
|
||||
+34
-521
@@ -1,11 +1,5 @@
|
||||
import type { Host, VaultNote } from "./models.ts";
|
||||
import { getNextVaultOrder, normalizeVaultOrder, sortByVaultOrder } from "./vaultOrder.ts";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough";
|
||||
import { toString as markdownNodeToString } from "mdast-util-to-string";
|
||||
import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
|
||||
|
||||
export type { Host, VaultNote };
|
||||
import type { Host, VaultNote } from "./models";
|
||||
import { getNextVaultOrder, normalizeVaultOrder, sortByVaultOrder } from "./vaultOrder";
|
||||
|
||||
const cleanStringArray = (values: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(values)) return undefined;
|
||||
@@ -44,7 +38,6 @@ export const sanitizeVaultNote = (note: Partial<VaultNote>): VaultNote => {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
order: typeof note.order === "number" && Number.isFinite(note.order) ? note.order : undefined,
|
||||
isPinned: note.isPinned ? true : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -179,12 +172,7 @@ export const sanitizeNoteExportFileNamePart = (value: string | undefined, fallba
|
||||
.replace(/\.+$/g, "")
|
||||
.trim();
|
||||
const safe = cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : fallback;
|
||||
const firstDotIndex = safe.indexOf(".");
|
||||
const windowsStem = (firstDotIndex >= 0 ? safe.slice(0, firstDotIndex) : safe).trimEnd();
|
||||
const extension = firstDotIndex >= 0 ? safe.slice(firstDotIndex) : "";
|
||||
const withoutReservedName = NOTE_EXPORT_RESERVED_WINDOWS_NAMES.test(windowsStem)
|
||||
? `${windowsStem}_${extension}`
|
||||
: safe;
|
||||
const withoutReservedName = NOTE_EXPORT_RESERVED_WINDOWS_NAMES.test(safe) ? `${safe}_` : safe;
|
||||
return withoutReservedName.slice(0, 120) || fallback;
|
||||
};
|
||||
|
||||
@@ -204,53 +192,21 @@ export const buildVaultNoteMarkdownExportFiles = (
|
||||
notes: VaultNote[],
|
||||
scope: VaultNotesExportScope = { type: "all" },
|
||||
): VaultNoteMarkdownExportFile[] => {
|
||||
const notesForExport = getVaultNotesForExportScope(notes, scope);
|
||||
const groupPaths = Array.from(new Set(
|
||||
notesForExport.flatMap((note) => note.group ? ancestorNoteGroupPaths(note.group) : []),
|
||||
)).sort((left, right) => {
|
||||
return left.split("/").length - right.split("/").length;
|
||||
});
|
||||
const exportedGroupSegments = new Map<string, string[]>();
|
||||
const usedDirectoryNamesByParent = new Map<string, Set<string>>();
|
||||
|
||||
for (const groupPath of groupPaths) {
|
||||
const parentPath = getNoteGroupParentPath(groupPath);
|
||||
const parentSegments = parentPath ? exportedGroupSegments.get(parentPath) ?? [] : [];
|
||||
const parentKey = parentSegments.join("/").toLowerCase();
|
||||
const usedDirectoryNames = usedDirectoryNamesByParent.get(parentKey) ?? new Set<string>();
|
||||
const baseName = sanitizeNoteExportFileNamePart(getNoteGroupLeafName(groupPath), "folder");
|
||||
let candidate = baseName;
|
||||
let suffix = 2;
|
||||
while (usedDirectoryNames.has(candidate.toLowerCase())) {
|
||||
candidate = `${baseName}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedDirectoryNames.add(candidate.toLowerCase());
|
||||
usedDirectoryNamesByParent.set(parentKey, usedDirectoryNames);
|
||||
exportedGroupSegments.set(groupPath, [...parentSegments, candidate]);
|
||||
}
|
||||
|
||||
const exportEntries = notesForExport.map((note, index) => ({
|
||||
note,
|
||||
groupSegments: note.group
|
||||
? exportedGroupSegments.get(cleanNoteGroupPath(note.group)) ?? []
|
||||
: [],
|
||||
baseName: sanitizeNoteExportFileNamePart(note.title, `note-${index + 1}`),
|
||||
}));
|
||||
const directoryNames = new Set<string>();
|
||||
for (const { groupSegments } of exportEntries) {
|
||||
for (let depth = 1; depth <= groupSegments.length; depth += 1) {
|
||||
directoryNames.add(groupSegments.slice(0, depth).join("/").toLowerCase());
|
||||
}
|
||||
}
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
return exportEntries.map(({ note, groupSegments, baseName }) => {
|
||||
return getVaultNotesForExportScope(notes, scope).map((note, index) => {
|
||||
const groupSegments = note.group
|
||||
? cleanNoteGroupPath(note.group)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part) => sanitizeNoteExportFileNamePart(part, "folder"))
|
||||
: [];
|
||||
const baseName = sanitizeNoteExportFileNamePart(note.title, `note-${index + 1}`);
|
||||
const basePath = [...groupSegments, baseName].join("/");
|
||||
let candidate = `${basePath}.md`;
|
||||
let suffix = 2;
|
||||
|
||||
while (usedNames.has(candidate.toLowerCase()) || directoryNames.has(candidate.toLowerCase())) {
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
candidate = `${basePath}-${suffix}.md`;
|
||||
suffix += 1;
|
||||
}
|
||||
@@ -398,9 +354,29 @@ export const resolveRenderedMarkdownLinkHref = (
|
||||
|
||||
const NOTE_IMPORT_TITLE_EXTENSIONS = /\.(md|markdown|txt)$/i;
|
||||
|
||||
const stripFencedCodeBlocksForImportTitle = (content: string): string => {
|
||||
const withoutClosedBlocks = content.replace(/```[\s\S]*?```/g, "");
|
||||
const lines = withoutClosedBlocks.split("\n");
|
||||
const output: string[] = [];
|
||||
let inUnclosedFence = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (/^```/.test(trimmed)) {
|
||||
inUnclosedFence = true;
|
||||
continue;
|
||||
}
|
||||
if (inUnclosedFence) continue;
|
||||
output.push(line);
|
||||
}
|
||||
|
||||
return output.join("\n");
|
||||
};
|
||||
|
||||
export const deriveNoteImportTitle = (fileName: string, content: string): string => {
|
||||
const heading = extractNoteHeadings(content).find((item) => item.level === 1);
|
||||
if (heading) return heading.text;
|
||||
const contentWithoutCodeBlocks = stripFencedCodeBlocksForImportTitle(content);
|
||||
const headingMatch = /^#\s+(.+?)\s*$/m.exec(contentWithoutCodeBlocks);
|
||||
if (headingMatch?.[1]) return headingMatch[1].trim();
|
||||
|
||||
const baseName = fileName.replace(NOTE_IMPORT_TITLE_EXTENSIONS, "").trim();
|
||||
return baseName || "Untitled note";
|
||||
@@ -486,466 +462,3 @@ export const importMarkdownFilesToVaultNotes = async (
|
||||
skippedCount,
|
||||
};
|
||||
};
|
||||
|
||||
export interface NoteHeadingItem {
|
||||
id: string;
|
||||
level: number;
|
||||
text: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export const normalizeNoteHeadingText = (value: string): string => value.replace(/\s+/g, " ").trim();
|
||||
|
||||
export const extractNoteHeadings = (content: string): NoteHeadingItem[] => {
|
||||
if (!content) return [];
|
||||
const headings: NoteHeadingItem[] = [];
|
||||
type AstNode = {
|
||||
type: string;
|
||||
depth?: number;
|
||||
children?: AstNode[];
|
||||
position?: { start?: { line?: number } };
|
||||
};
|
||||
const visit = (node: AstNode) => {
|
||||
if (node.type === "heading" && node.depth) {
|
||||
const text = normalizeNoteHeadingText(
|
||||
markdownNodeToString(node as Parameters<typeof markdownNodeToString>[0]),
|
||||
);
|
||||
const line = node.position?.start?.line ?? 1;
|
||||
if (text) {
|
||||
headings.push({
|
||||
id: `heading-${line - 1}-${text.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-")}`,
|
||||
level: node.depth,
|
||||
text,
|
||||
line,
|
||||
});
|
||||
}
|
||||
}
|
||||
node.children?.forEach(visit);
|
||||
};
|
||||
visit(
|
||||
fromMarkdown(content, {
|
||||
extensions: [gfmStrikethrough()],
|
||||
mdastExtensions: [gfmStrikethroughFromMarkdown()],
|
||||
}) as AstNode,
|
||||
);
|
||||
return headings;
|
||||
};
|
||||
|
||||
export const extractNoteSnippet = (content: string, maxLength = 100): string => {
|
||||
if (!content) return "";
|
||||
let text = content.replace(/```[\s\S]*?```/g, " ");
|
||||
text = text.replace(/`([^`]+)`/g, "$1");
|
||||
text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||
text = text.replace(/^#{1,6}\s+/gm, "");
|
||||
text = text.replace(/^>\s+/gm, "");
|
||||
text = text.replace(/^[-*+]\s+(\[[ xX]\]\s+)?/gm, "");
|
||||
text = text.replace(/^\d+\.\s+/gm, "");
|
||||
text = text.replace(/\s+/g, " ").trim();
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trim()}...`;
|
||||
};
|
||||
|
||||
export const extractAllNoteTags = (notes: VaultNote[]): { tag: string; count: number }[] => {
|
||||
const map = new Map<string, number>();
|
||||
for (const note of notes) {
|
||||
if (note.tags) {
|
||||
for (const t of note.tags) {
|
||||
const clean = t.trim();
|
||||
if (clean) {
|
||||
map.set(clean, (map.get(clean) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
};
|
||||
|
||||
export type NoteSortOption =
|
||||
| "updatedDesc"
|
||||
| "updatedAsc"
|
||||
| "createdDesc"
|
||||
| "createdAsc"
|
||||
| "titleAsc"
|
||||
| "titleDesc"
|
||||
| "custom";
|
||||
|
||||
export type NoteFilterMode = "all" | "pinned" | "recent" | "uncategorized";
|
||||
|
||||
export const filterAndSortVaultNotes = (
|
||||
notes: VaultNote[],
|
||||
options: {
|
||||
search?: string;
|
||||
group?: string | null;
|
||||
tag?: string | null;
|
||||
filterMode?: NoteFilterMode;
|
||||
sort?: NoteSortOption;
|
||||
hosts?: Host[];
|
||||
} = {},
|
||||
): VaultNote[] => {
|
||||
const {
|
||||
search = "",
|
||||
group = null,
|
||||
tag = null,
|
||||
filterMode = "all",
|
||||
sort = "updatedDesc",
|
||||
hosts = [],
|
||||
} = options;
|
||||
|
||||
const filtered = notes.filter((note) => {
|
||||
if (filterMode === "pinned" && !note.isPinned) return false;
|
||||
if (filterMode === "uncategorized" && note.group) return false;
|
||||
if (filterMode === "recent") {
|
||||
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
if (note.updatedAt < sevenDaysAgo) return false;
|
||||
}
|
||||
|
||||
if (group) {
|
||||
if (!note.group || !isNoteGroupInside(note.group, group)) return false;
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
if (!note.tags || !note.tags.includes(tag)) return false;
|
||||
}
|
||||
|
||||
if (search.trim() && !matchesVaultNoteSearch(note, search, hosts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (sort !== "custom") {
|
||||
if (a.isPinned !== b.isPinned) {
|
||||
return a.isPinned ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
switch (sort) {
|
||||
case "updatedDesc":
|
||||
return b.updatedAt - a.updatedAt;
|
||||
case "updatedAsc":
|
||||
return a.updatedAt - b.updatedAt;
|
||||
case "createdDesc":
|
||||
return b.createdAt - a.createdAt;
|
||||
case "createdAsc":
|
||||
return a.createdAt - b.createdAt;
|
||||
case "titleAsc":
|
||||
return (a.title || "").localeCompare(b.title || "");
|
||||
case "titleDesc":
|
||||
return (b.title || "").localeCompare(a.title || "");
|
||||
case "custom":
|
||||
default:
|
||||
return (a.order ?? 0) - (b.order ?? 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export interface NoteStats {
|
||||
words: number;
|
||||
chars: number;
|
||||
lines: number;
|
||||
}
|
||||
|
||||
export const calculateNoteStats = (content: string): NoteStats => {
|
||||
if (!content) return { words: 0, chars: 0, lines: 0 };
|
||||
const lines = content.split("\n").length;
|
||||
const chars = content.length;
|
||||
const cjkMatches = content.match(/[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/g);
|
||||
const cjkChars = cjkMatches ? cjkMatches.length : 0;
|
||||
const nonCjkWords = content
|
||||
.replace(/[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length;
|
||||
const words = cjkChars + nonCjkWords;
|
||||
return { words, chars, lines };
|
||||
};
|
||||
|
||||
export type MarkdownActionType =
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "bold"
|
||||
| "italic"
|
||||
| "strikethrough"
|
||||
| "underline"
|
||||
| "code"
|
||||
| "codeblock"
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "quote"
|
||||
| "bullet"
|
||||
| "number"
|
||||
| "task"
|
||||
| "table"
|
||||
| "divider"
|
||||
| "link"
|
||||
| "image"
|
||||
| "math";
|
||||
|
||||
export interface WrapMarkdownResult {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
const formatSelectedMarkdownListLines = (
|
||||
content: string,
|
||||
marker: (index: number) => string,
|
||||
): { formatted: string; selectionStartOffset: number; selectionEndOffset: number } => {
|
||||
let itemIndex = 0;
|
||||
let cursor = 0;
|
||||
let selectionStartOffset: number | null = null;
|
||||
let selectionEndOffset: number | null = null;
|
||||
const formattedLines = content.split("\n").map((line) => {
|
||||
if (!line.trim()) {
|
||||
cursor += line.length + 1;
|
||||
return line;
|
||||
}
|
||||
const prefix = marker(itemIndex);
|
||||
itemIndex += 1;
|
||||
const formattedLine = `${prefix}${line}`;
|
||||
if (selectionStartOffset === null) selectionStartOffset = cursor + prefix.length;
|
||||
selectionEndOffset = cursor + formattedLine.length;
|
||||
cursor += formattedLine.length + 1;
|
||||
return formattedLine;
|
||||
});
|
||||
return {
|
||||
formatted: formattedLines.join("\n"),
|
||||
selectionStartOffset: selectionStartOffset ?? 0,
|
||||
selectionEndOffset: selectionEndOffset ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatMarkdownListSelection = (
|
||||
content: string,
|
||||
action: "bullet" | "number" | "task",
|
||||
): string => formatSelectedMarkdownListLines(
|
||||
content,
|
||||
action === "number"
|
||||
? (index) => `${index + 1}. `
|
||||
: action === "task"
|
||||
? () => "- [ ] "
|
||||
: () => "- ",
|
||||
).formatted;
|
||||
|
||||
const formatSelectedMarkdownQuoteLines = (content: string): {
|
||||
formatted: string;
|
||||
selectionStartOffset: number;
|
||||
selectionEndOffset: number;
|
||||
} => {
|
||||
let cursor = 0;
|
||||
let selectionStartOffset: number | null = null;
|
||||
let selectionEndOffset: number | null = null;
|
||||
const formattedLines = content.split("\n").map((line) => {
|
||||
const prefix = line ? "> " : ">";
|
||||
const formattedLine = `${prefix}${line}`;
|
||||
if (line) {
|
||||
if (selectionStartOffset === null) selectionStartOffset = cursor + prefix.length;
|
||||
selectionEndOffset = cursor + formattedLine.length;
|
||||
}
|
||||
cursor += formattedLine.length + 1;
|
||||
return formattedLine;
|
||||
});
|
||||
return {
|
||||
formatted: formattedLines.join("\n"),
|
||||
selectionStartOffset: selectionStartOffset ?? 0,
|
||||
selectionEndOffset: selectionEndOffset ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatMarkdownQuoteSelection = (content: string): string =>
|
||||
formatSelectedMarkdownQuoteLines(content).formatted;
|
||||
|
||||
export const wrapMarkdownSyntax = (
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
action: MarkdownActionType,
|
||||
): WrapMarkdownResult => {
|
||||
const before = text.slice(0, start);
|
||||
const selected = text.slice(start, end);
|
||||
const after = text.slice(end);
|
||||
const selectedBlockContent = (fallback: string): string => selected.trim() ? selected : fallback;
|
||||
|
||||
switch (action) {
|
||||
case "bold": {
|
||||
const content = selected || "bold text";
|
||||
return {
|
||||
text: `${before}**${content}**${after}`,
|
||||
selectionStart: start + 2,
|
||||
selectionEnd: start + 2 + content.length,
|
||||
};
|
||||
}
|
||||
case "italic": {
|
||||
const content = selected || "italic text";
|
||||
return {
|
||||
text: `${before}*${content}*${after}`,
|
||||
selectionStart: start + 1,
|
||||
selectionEnd: start + 1 + content.length,
|
||||
};
|
||||
}
|
||||
case "strikethrough": {
|
||||
const content = selected || "strikethrough text";
|
||||
return {
|
||||
text: `${before}~~${content}~~${after}`,
|
||||
selectionStart: start + 2,
|
||||
selectionEnd: start + 2 + content.length,
|
||||
};
|
||||
}
|
||||
case "underline": {
|
||||
const content = selected || "underlined text";
|
||||
return {
|
||||
text: `${before}<u>${content}</u>${after}`,
|
||||
selectionStart: start + 3,
|
||||
selectionEnd: start + 3 + content.length,
|
||||
};
|
||||
}
|
||||
case "code": {
|
||||
const content = selected || "code";
|
||||
return {
|
||||
text: `${before}\`${content}\`${after}`,
|
||||
selectionStart: start + 1,
|
||||
selectionEnd: start + 1 + content.length,
|
||||
};
|
||||
}
|
||||
case "codeblock": {
|
||||
const content = selected || "";
|
||||
const opening = "\n```bash\n";
|
||||
const insert = `${opening}${content}\n\`\`\`\n`;
|
||||
return {
|
||||
text: `${before}${insert}${after}`,
|
||||
selectionStart: start + opening.length,
|
||||
selectionEnd: start + opening.length + content.length,
|
||||
};
|
||||
}
|
||||
case "h1": {
|
||||
const content = selected || "Heading 1";
|
||||
return {
|
||||
text: `${before}\n# ${content}\n${after}`,
|
||||
selectionStart: start + 3,
|
||||
selectionEnd: start + 3 + content.length,
|
||||
};
|
||||
}
|
||||
case "h2": {
|
||||
const content = selected || "Heading 2";
|
||||
return {
|
||||
text: `${before}\n## ${content}\n${after}`,
|
||||
selectionStart: start + 4,
|
||||
selectionEnd: start + 4 + content.length,
|
||||
};
|
||||
}
|
||||
case "h3": {
|
||||
const content = selected || "Heading 3";
|
||||
return {
|
||||
text: `${before}\n### ${content}\n${after}`,
|
||||
selectionStart: start + 5,
|
||||
selectionEnd: start + 5 + content.length,
|
||||
};
|
||||
}
|
||||
case "h4": {
|
||||
const content = selected || "Heading 4";
|
||||
return {
|
||||
text: `${before}\n#### ${content}\n${after}`,
|
||||
selectionStart: start + 6,
|
||||
selectionEnd: start + 6 + content.length,
|
||||
};
|
||||
}
|
||||
case "quote": {
|
||||
const content = selectedBlockContent("Quote");
|
||||
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownQuoteLines(content);
|
||||
return {
|
||||
text: `${before}\n${formatted}\n${after}`,
|
||||
selectionStart: start + 1 + selectionStartOffset,
|
||||
selectionEnd: start + 1 + selectionEndOffset,
|
||||
};
|
||||
}
|
||||
case "bullet": {
|
||||
const content = selectedBlockContent("List item");
|
||||
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownListLines(
|
||||
content,
|
||||
() => "- ",
|
||||
);
|
||||
return {
|
||||
text: `${before}\n${formatted}\n${after}`,
|
||||
selectionStart: start + 1 + selectionStartOffset,
|
||||
selectionEnd: start + 1 + selectionEndOffset,
|
||||
};
|
||||
}
|
||||
case "number": {
|
||||
const content = selectedBlockContent("List item");
|
||||
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownListLines(
|
||||
content,
|
||||
(index) => `${index + 1}. `,
|
||||
);
|
||||
return {
|
||||
text: `${before}\n${formatted}\n${after}`,
|
||||
selectionStart: start + 1 + selectionStartOffset,
|
||||
selectionEnd: start + 1 + selectionEndOffset,
|
||||
};
|
||||
}
|
||||
case "task": {
|
||||
const content = selectedBlockContent("Task");
|
||||
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownListLines(
|
||||
content,
|
||||
() => "- [ ] ",
|
||||
);
|
||||
return {
|
||||
text: `${before}\n${formatted}\n${after}`,
|
||||
selectionStart: start + 1 + selectionStartOffset,
|
||||
selectionEnd: start + 1 + selectionEndOffset,
|
||||
};
|
||||
}
|
||||
case "divider": {
|
||||
const insert = `\n---\n`;
|
||||
return {
|
||||
text: `${before}${insert}${after}`,
|
||||
selectionStart: start + insert.length,
|
||||
selectionEnd: start + insert.length,
|
||||
};
|
||||
}
|
||||
case "table": {
|
||||
const tableMarkdown = `\n| Column 1 | Column 2 | Column 3 |\n| :--- | :--- | :--- |\n| Cell 1 | Cell 2 | Cell 3 |\n`;
|
||||
return {
|
||||
text: `${before}${tableMarkdown}${after}`,
|
||||
selectionStart: start + tableMarkdown.length,
|
||||
selectionEnd: start + tableMarkdown.length,
|
||||
};
|
||||
}
|
||||
case "link": {
|
||||
const title = selected || "Link text";
|
||||
const insert = `[${title}](https://)`;
|
||||
return {
|
||||
text: `${before}${insert}${after}`,
|
||||
selectionStart: start + title.length + 3,
|
||||
selectionEnd: start + title.length + 11,
|
||||
};
|
||||
}
|
||||
case "image": {
|
||||
const alt = selected || "Image description";
|
||||
const insert = ``;
|
||||
return {
|
||||
text: `${before}${insert}${after}`,
|
||||
selectionStart: start + alt.length + 4,
|
||||
selectionEnd: start + alt.length + 12,
|
||||
};
|
||||
}
|
||||
case "math": {
|
||||
const content = selected || "";
|
||||
const opening = "\n```latex\n";
|
||||
const insert = `${opening}${content}\n\`\`\`\n`;
|
||||
return {
|
||||
text: `${before}${insert}${after}`,
|
||||
selectionStart: start + opening.length,
|
||||
selectionEnd: start + opening.length + content.length,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { text, selectionStart: start, selectionEnd: end };
|
||||
}
|
||||
};
|
||||
@@ -896,7 +896,7 @@ export const resolveNoteClipboardPaste = (input: {
|
||||
};
|
||||
|
||||
export const shouldInterceptResolvedNotePaste = (input: {
|
||||
editorMode: "edit" | "preview" | "source" | "live" | string;
|
||||
editorMode: "edit" | "preview";
|
||||
pasteInsideCodeBlock: boolean;
|
||||
payload: NoteClipboardPastePayload;
|
||||
}): boolean => {
|
||||
|
||||
@@ -302,9 +302,6 @@ export interface SyncPayload {
|
||||
uiFontFamilyId?: string;
|
||||
uiLanguage?: string;
|
||||
customCSS?: string;
|
||||
noteFontFamily?: string;
|
||||
noteFontSize?: number;
|
||||
noteCodeFontSize?: number;
|
||||
// Terminal
|
||||
terminalTheme?: string;
|
||||
followAppTerminalTheme?: boolean;
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const ESCAPE_SEQUENCE = "\\x" + "1b";
|
||||
const BELL_SEQUENCE = "\\x" + "07";
|
||||
const ANSI_CONTROL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[[0-?]*[ -/]*[@-~]`, "gu");
|
||||
const OSC_CONTROL_PATTERN = new RegExp(
|
||||
`${ESCAPE_SEQUENCE}\\][^${BELL_SEQUENCE}]*(?:${BELL_SEQUENCE}|${ESCAPE_SEQUENCE}\\\\)`,
|
||||
"gu",
|
||||
);
|
||||
const MAX_PROMPT_SECURITY_TAIL_CHARS = 2_048;
|
||||
|
||||
const SENSITIVE_ENGLISH_LABEL = [
|
||||
String.raw`pass(?:word|phrase|code)`,
|
||||
String.raw`one[\s-]?time(?:\s+(?:password|passcode|code|token))?`,
|
||||
String.raw`\botp\b`,
|
||||
String.raw`verification(?:\s+(?:code|token|passcode))?`,
|
||||
String.raw`authentication\s+(?:code|token|passcode)`,
|
||||
String.raw`security\s+(?:code|token|passcode|pin)`,
|
||||
String.raw`\bpin\b`,
|
||||
String.raw`\btoken\b`,
|
||||
String.raw`2fa`,
|
||||
String.raw`two[\s-]?factor`,
|
||||
String.raw`multi[\s-]?factor`,
|
||||
String.raw`\bmfa\b`,
|
||||
String.raw`second\s+factor`,
|
||||
String.raw`secondary(?:\s+\w+){0,3}\s+passw(?:ord)?`,
|
||||
String.raw`second(?:\s+\w+){0,3}\s+passw(?:ord)?`,
|
||||
String.raw`additional(?:\s+\w+){0,3}\s+passw(?:ord)?`,
|
||||
String.raw`re[-\s]?enter\s+passw(?:ord)?`,
|
||||
String.raw`confirm\s+passw(?:ord)?`,
|
||||
String.raw`\bedr\b`,
|
||||
String.raw`\bduo\b`,
|
||||
].join("|");
|
||||
|
||||
const SENSITIVE_CJK_LABEL = [
|
||||
"密码", "口令", "动态", "一次性", "验证码", "验证信息", "令牌", "双因素", "多因素",
|
||||
"短信验证", "手机验证", "二次", "安全密码", "挑战码",
|
||||
].join("|");
|
||||
|
||||
const SENSITIVE_LABEL_PATTERN = new RegExp(
|
||||
`(?:${SENSITIVE_ENGLISH_LABEL}|${SENSITIVE_CJK_LABEL})`,
|
||||
"iu",
|
||||
);
|
||||
|
||||
function stripTerminalControlSequences(value) {
|
||||
return String(value || "").replace(OSC_CONTROL_PATTERN, "").replace(ANSI_CONTROL_PATTERN, "");
|
||||
}
|
||||
|
||||
function lastLogicalLine(value) {
|
||||
const plain = stripTerminalControlSequences(value);
|
||||
const boundary = Math.max(plain.lastIndexOf("\n"), plain.lastIndexOf("\r"));
|
||||
return plain.slice(boundary + 1).slice(-MAX_PROMPT_SECURITY_TAIL_CHARS);
|
||||
}
|
||||
|
||||
function isSensitiveTerminalChallenge(value) {
|
||||
const line = lastLogicalLine(value).trim();
|
||||
if (!line) return false;
|
||||
const label = SENSITIVE_LABEL_PATTERN.exec(line);
|
||||
if (!label) return false;
|
||||
const prefix = line.slice(0, label.index || 0).trim();
|
||||
const suffix = line.slice((label.index || 0) + label[0].length);
|
||||
if (suffix.trim().length === 0) {
|
||||
return prefix.length === 0
|
||||
|| /(?:^|\s)(?:enter|input|provide|type|scan|please|your|current|new|old)\s*$/iu.test(prefix)
|
||||
|| /(?:输入|请输入|请)\s*$/u.test(prefix);
|
||||
}
|
||||
if (/^\s+(?:for|of)\s+[^\r\n]{1,96}$/iu.test(suffix)) return true;
|
||||
return /^[^\r\n::>›»]{0,96}[::>›»]\s*[^\r\n]{0,1024}$/u.test(suffix);
|
||||
}
|
||||
|
||||
function isConfirmedTerminalShellPrompt(promptText, options = {}) {
|
||||
const prompt = lastLogicalLine(promptText).trim();
|
||||
if (!prompt || isSensitiveTerminalChallenge(prompt)) return false;
|
||||
if (/[❯❮→➜➤⟩»›]/u.test(prompt)) return true;
|
||||
for (const character of prompt) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (code >= 0xE000 && code <= 0xF8FF) return true;
|
||||
}
|
||||
if (/[$#%]$/u.test(prompt)) return true;
|
||||
if (!prompt.endsWith(">")) return false;
|
||||
if (/^(?:PS\s+)?[A-Za-z]:[\\/].*>$/u.test(prompt)) return true;
|
||||
if (/[@\\/~:]\S*>$/u.test(prompt)) return true;
|
||||
return options.allowHostStyleGreaterThan === true
|
||||
&& /^[A-Za-z0-9_.-]+(?:\([^)]{1,128}\))?>$/u.test(prompt);
|
||||
}
|
||||
|
||||
function isPlausibleShellPromptBody(body) {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return true;
|
||||
if (/@|[/\\~:]/u.test(trimmed)) return true;
|
||||
if (/^(?:bash|zsh|sh|fish|ksh|csh|tcsh|dash)(?:-[\d.]+)?$/iu.test(trimmed)) return true;
|
||||
return /^[a-z0-9_.-]+$/u.test(trimmed);
|
||||
}
|
||||
|
||||
function hasTypedInputAfterConfirmedPrompt(line, options = {}) {
|
||||
for (let i = 0; i < line.length - 1; i += 1) {
|
||||
const ch = line[i];
|
||||
if (ch !== "$" && ch !== "#" && ch !== "%" && ch !== ">") continue;
|
||||
const rest = line.slice(i + 1);
|
||||
if (!/^\s+\S/u.test(rest)) continue;
|
||||
const candidate = line.slice(0, i + 1);
|
||||
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
|
||||
if ((ch === "$" || ch === "#" || ch === "%") && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isUntrustedTerminalInputPrompt(value, options = {}) {
|
||||
const prompt = lastLogicalLine(value).trim();
|
||||
if (!prompt) return false;
|
||||
if (isSensitiveTerminalChallenge(prompt)) return true;
|
||||
if (!/[::>›»]\s*$/u.test(prompt)) return false;
|
||||
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
|
||||
return !isConfirmedTerminalShellPrompt(prompt, options);
|
||||
}
|
||||
|
||||
module.exports = { isUntrustedTerminalInputPrompt };
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
@@ -10,11 +9,6 @@ import {
|
||||
shouldUsePluginTerminalCompletionProvider,
|
||||
} from './terminalPromptSecurity.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const sharedPromptSecurity = require('./terminalPromptSecurity.shared.cjs') as {
|
||||
isUntrustedTerminalInputPrompt: typeof isUntrustedTerminalInputPrompt;
|
||||
};
|
||||
|
||||
test('terminal prompt security recognizes password, MFA, OTP, PIN, and CJK challenges', () => {
|
||||
const challenges = [
|
||||
'Password: ',
|
||||
@@ -121,24 +115,3 @@ test('ordinary shell commands ending with a colon are not untrusted input prompt
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Token: '), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password: '), true);
|
||||
});
|
||||
|
||||
test('renderer and main-process prompt classifiers stay aligned', () => {
|
||||
const cases: Array<[
|
||||
string,
|
||||
{ allowHostStyleGreaterThan?: boolean } | undefined,
|
||||
boolean,
|
||||
]> = [
|
||||
['Custom authentication> ', undefined, true],
|
||||
['Please authenticate: ', undefined, true],
|
||||
['alice@host:~$ ', undefined, false],
|
||||
['user@host:~$ lsof -i:', undefined, false],
|
||||
['Challenge # 1:', undefined, true],
|
||||
['router> show ip:', { allowHostStyleGreaterThan: true }, false],
|
||||
['router> show ip:', undefined, true],
|
||||
];
|
||||
|
||||
for (const [prompt, options, expected] of cases) {
|
||||
assert.equal(isUntrustedTerminalInputPrompt(prompt, options), expected, prompt);
|
||||
assert.equal(sharedPromptSecurity.isUntrustedTerminalInputPrompt(prompt, options), expected, prompt);
|
||||
}
|
||||
});
|
||||
@@ -65,9 +65,6 @@ module.exports = {
|
||||
// (terminalFlowAck.cjs). Must ship beside electron/ in app.asar.
|
||||
'infrastructure/config/terminalFlowConstants.cjs',
|
||||
'infrastructure/config/terminalFlowConstants.json',
|
||||
// Renderer and Mosh's main-process bootstrap share the same
|
||||
// fail-closed prompt classifier at runtime.
|
||||
'domain/terminalPromptSecurity.shared.cjs',
|
||||
'lib/**/*.cjs',
|
||||
'lib/**/*.json',
|
||||
'skills/**/*',
|
||||
@@ -115,7 +112,6 @@ module.exports = {
|
||||
'!node_modules/lexical/**/*',
|
||||
'!node_modules/@lexical/**/*',
|
||||
'!node_modules/@codemirror/**/*',
|
||||
'!node_modules/katex/**/*',
|
||||
'!node_modules/shiki/**/*',
|
||||
'!node_modules/@shiki/**/*',
|
||||
// Heavy cloud completion specs are intentionally not bundled. The main
|
||||
|
||||
@@ -21,13 +21,6 @@ function channelAbortError(signal, label) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function monotonicNow() {
|
||||
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bastion / jump hosts sometimes reject rapid session channel opens with a
|
||||
* distinctive rate-limit message. The common Chinese bastion typo "offen"
|
||||
@@ -39,37 +32,9 @@ function isSshChannelOpenRateLimitedError(error) {
|
||||
|| /channelOpen\s+too\s+often\b/i.test(message);
|
||||
}
|
||||
|
||||
function sleep(ms, sleepFn = null, signal = null, label = "SSH channel retry") {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
signal?.removeEventListener?.("abort", onAbort);
|
||||
};
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const onAbort = () => finish(channelAbortError(signal, label));
|
||||
|
||||
if (signal?.aborted) {
|
||||
finish(channelAbortError(signal, label));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener?.("abort", onAbort, { once: true });
|
||||
if (typeof sleepFn === "function") {
|
||||
void Promise.resolve()
|
||||
.then(() => sleepFn(ms))
|
||||
.then(() => finish(), (error) => finish(error));
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => finish(), ms);
|
||||
});
|
||||
function sleep(ms, sleepFn = null) {
|
||||
if (typeof sleepFn === "function") return Promise.resolve(sleepFn(ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function openBoundedSshChannel(sshClient, invoke, options = {}) {
|
||||
@@ -81,47 +46,31 @@ function openBoundedSshChannel(sshClient, invoke, options = {}) {
|
||||
const signal = options.signal || null;
|
||||
const closeLateResult = options.closeLateResult || closeLateChannel;
|
||||
const timeoutCode = options.timeoutCode || "SSH_CHANNEL_OPEN_TIMEOUT";
|
||||
const invalidateOnAbort = options.invalidateOnAbort !== false;
|
||||
const invalidateOnTimeout = options.invalidateOnTimeout !== false;
|
||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let invoked = false;
|
||||
let abandoned = false;
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeoutFn(timer);
|
||||
timer = null;
|
||||
signal?.removeEventListener?.("abort", onAbort);
|
||||
};
|
||||
const finish = (error, result, { invalidate = false, abandon = false } = {}) => {
|
||||
const finish = (error, result, { invalidate = false } = {}) => {
|
||||
if (settled) {
|
||||
if (result) closeLateResult(result);
|
||||
if (abandoned) {
|
||||
abandoned = false;
|
||||
try { options.onAbandonedOpenSettled?.(); } catch { /* ignore */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error && result) closeLateResult(result);
|
||||
if (abandon && invoked) {
|
||||
abandoned = true;
|
||||
try { options.onAbandonedOpen?.(); } catch { /* ignore */ }
|
||||
}
|
||||
if (invalidate) invalidateSshTransport(sshClient);
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
return true;
|
||||
};
|
||||
const onAbort = () => finish(
|
||||
channelAbortError(signal, label),
|
||||
null,
|
||||
{ invalidate: invalidateOnAbort, abandon: !invalidateOnAbort },
|
||||
);
|
||||
const onAbort = () => finish(channelAbortError(signal, label), null, { invalidate: true });
|
||||
|
||||
if (signal?.aborted) {
|
||||
finish(channelAbortError(signal, label));
|
||||
@@ -134,14 +83,10 @@ function openBoundedSshChannel(sshClient, invoke, options = {}) {
|
||||
timer = setTimeoutFn(() => {
|
||||
const error = new Error(`${label} timed out after ${timeoutMs} ms`);
|
||||
error.code = timeoutCode;
|
||||
finish(error, null, {
|
||||
invalidate: invalidateOnTimeout,
|
||||
abandon: !invalidateOnTimeout,
|
||||
});
|
||||
finish(error, null, { invalidate: true });
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
invoked = true;
|
||||
invoke((error, result) => finish(error, result));
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
@@ -150,96 +95,40 @@ function openBoundedSshChannel(sshClient, invoke, options = {}) {
|
||||
}
|
||||
|
||||
async function openBoundedSshShell(sshClient, windowOptions, shellOptions, options = {}) {
|
||||
const hasRateLimitRetryTimeout = Number.isFinite(options.rateLimitRetryTimeoutMs);
|
||||
const hasExplicitRateLimitRetries = Number.isFinite(options.rateLimitRetries);
|
||||
const rateLimitRetries = Math.max(
|
||||
0,
|
||||
hasExplicitRateLimitRetries
|
||||
Number.isFinite(options.rateLimitRetries)
|
||||
? Number(options.rateLimitRetries)
|
||||
: hasRateLimitRetryTimeout
|
||||
? Number.POSITIVE_INFINITY
|
||||
: DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES,
|
||||
: DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES,
|
||||
);
|
||||
const rateLimitRetryTimeoutMs = hasRateLimitRetryTimeout
|
||||
? Math.max(0, Number(options.rateLimitRetryTimeoutMs))
|
||||
: null;
|
||||
const rateLimitBackoffMs = Math.max(
|
||||
1,
|
||||
Number(options.rateLimitBackoffMs) || DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS,
|
||||
);
|
||||
const sleepFn = options.sleepFn || null;
|
||||
const nowFn = typeof options.nowFn === "function" ? options.nowFn : monotonicNow;
|
||||
const retryStartedAt = nowFn();
|
||||
const retryDeadline = rateLimitRetryTimeoutMs === null
|
||||
? null
|
||||
: retryStartedAt + rateLimitRetryTimeoutMs;
|
||||
let attempt = 0;
|
||||
let lastRateLimitError = null;
|
||||
|
||||
for (;;) {
|
||||
const attemptStartedAt = nowFn();
|
||||
if (
|
||||
lastRateLimitError
|
||||
&& retryDeadline !== null
|
||||
&& attemptStartedAt >= retryDeadline
|
||||
) {
|
||||
throw lastRateLimitError;
|
||||
}
|
||||
const configuredAttemptTimeoutMs = Math.max(
|
||||
1,
|
||||
Number(options.timeoutMs) || DEFAULT_SSH_CHANNEL_OPEN_TIMEOUT_MS,
|
||||
);
|
||||
const isRateLimitRetry = lastRateLimitError !== null;
|
||||
const attemptTimeoutMs = retryDeadline === null || !isRateLimitRetry
|
||||
? configuredAttemptTimeoutMs
|
||||
: Math.max(
|
||||
1,
|
||||
Math.min(configuredAttemptTimeoutMs, Math.ceil(retryDeadline - attemptStartedAt)),
|
||||
);
|
||||
const retryBudgetConstrainsAttempt = isRateLimitRetry
|
||||
&& attemptTimeoutMs < configuredAttemptTimeoutMs;
|
||||
try {
|
||||
return await openBoundedSshChannel(
|
||||
sshClient,
|
||||
(callback) => sshClient.shell(windowOptions, shellOptions, callback),
|
||||
{
|
||||
...options,
|
||||
timeoutMs: attemptTimeoutMs,
|
||||
invalidateOnTimeout: retryBudgetConstrainsAttempt
|
||||
? false
|
||||
: options.invalidateOnTimeout,
|
||||
label: options.label || "SSH shell channel open",
|
||||
timeoutCode: "SSH_SHELL_OPEN_TIMEOUT",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
retryBudgetConstrainsAttempt
|
||||
&& error?.code === "SSH_SHELL_OPEN_TIMEOUT"
|
||||
&& lastRateLimitError
|
||||
) {
|
||||
throw lastRateLimitError;
|
||||
}
|
||||
if (!isSshChannelOpenRateLimitedError(error) || options.signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
lastRateLimitError = error;
|
||||
const nextDelayMs = rateLimitBackoffMs * (attempt + 1);
|
||||
const retryTimeoutExpired = rateLimitRetryTimeoutMs !== null
|
||||
&& nowFn() + nextDelayMs >= retryDeadline;
|
||||
if (
|
||||
attempt >= rateLimitRetries
|
||||
|| retryTimeoutExpired
|
||||
|| !isSshChannelOpenRateLimitedError(error)
|
||||
|| options.signal?.aborted
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
attempt += 1;
|
||||
await sleep(
|
||||
nextDelayMs,
|
||||
sleepFn,
|
||||
options.signal,
|
||||
options.label || "SSH shell channel retry",
|
||||
);
|
||||
await sleep(rateLimitBackoffMs * attempt, sleepFn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,9 @@ const {
|
||||
|
||||
function trackedTimerApi() {
|
||||
const active = new Set();
|
||||
const delays = [];
|
||||
return {
|
||||
active,
|
||||
delays,
|
||||
setTimeoutFn(callback, delay) {
|
||||
delays.push(delay);
|
||||
let timer;
|
||||
timer = setTimeout(() => {
|
||||
active.delete(timer);
|
||||
@@ -88,34 +85,6 @@ test("cancelled channel open invalidates transport and a late stream is closed",
|
||||
assert.equal(timers.active.size, 0);
|
||||
});
|
||||
|
||||
test("non-invalidating cancellation reports an abandoned open until its callback settles", async () => {
|
||||
let callback;
|
||||
let abandoned = 0;
|
||||
let abandonedSettled = 0;
|
||||
const controller = new AbortController();
|
||||
const client = {
|
||||
shell(_window, _options, next) { callback = next; },
|
||||
};
|
||||
const pending = openBoundedSshShell(client, {}, {}, {
|
||||
signal: controller.signal,
|
||||
invalidateOnAbort: false,
|
||||
onAbandonedOpen: () => { abandoned += 1; },
|
||||
onAbandonedOpenSettled: () => { abandonedSettled += 1; },
|
||||
});
|
||||
|
||||
controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(pending, /cancelled/);
|
||||
assert.equal(abandoned, 1);
|
||||
assert.equal(abandonedSettled, 0);
|
||||
|
||||
const lateStream = new EventEmitter();
|
||||
lateStream.closed = 0;
|
||||
lateStream.close = () => { lateStream.closed += 1; };
|
||||
callback(null, lateStream);
|
||||
assert.equal(lateStream.closed, 1);
|
||||
assert.equal(abandonedSettled, 1);
|
||||
});
|
||||
|
||||
test("channel open keeps its deadline referenced and clears it after success", async () => {
|
||||
let callback;
|
||||
const timers = trackedTimerApi();
|
||||
@@ -138,89 +107,6 @@ test("channel open keeps its deadline referenced and clears it after success", a
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
});
|
||||
|
||||
test("shell open caps each attempt to the remaining rate-limit retry window", async () => {
|
||||
const timers = trackedTimerApi();
|
||||
const controller = new AbortController();
|
||||
let elapsedMs = 0;
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
}
|
||||
},
|
||||
};
|
||||
const pending = openBoundedSshShell(client, {}, {}, {
|
||||
signal: controller.signal,
|
||||
timeoutMs: 1_000,
|
||||
rateLimitRetryTimeoutMs: 5,
|
||||
rateLimitBackoffMs: 1,
|
||||
nowFn: () => elapsedMs,
|
||||
sleepFn: async (ms) => { elapsedMs += ms; },
|
||||
...timers,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(attempts, 2);
|
||||
assert.deepEqual(timers.delays, [1_000, 4]);
|
||||
controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(pending, /cancelled/);
|
||||
});
|
||||
|
||||
test("a retry-budget timeout preserves the transport and returns the rate-limit error", async () => {
|
||||
let attempts = 0;
|
||||
let invalidations = 0;
|
||||
let elapsedMs = 0;
|
||||
let lateCallback = null;
|
||||
let abandoned = 0;
|
||||
let abandonedSettled = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
} else {
|
||||
lateCallback = next;
|
||||
}
|
||||
},
|
||||
destroy() { invalidations += 1; },
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSshShell(client, {}, {}, {
|
||||
timeoutMs: 1_000,
|
||||
rateLimitRetryTimeoutMs: 5,
|
||||
rateLimitBackoffMs: 1,
|
||||
nowFn: () => elapsedMs,
|
||||
sleepFn: async (ms) => { elapsedMs += ms; },
|
||||
setTimeoutFn(callback, ms) {
|
||||
if (ms < 1_000) {
|
||||
elapsedMs += ms;
|
||||
setImmediate(callback);
|
||||
}
|
||||
return { unref() {} };
|
||||
},
|
||||
clearTimeoutFn() {},
|
||||
onAbandonedOpen: () => { abandoned += 1; },
|
||||
onAbandonedOpenSettled: () => { abandonedSettled += 1; },
|
||||
}),
|
||||
/channelOpen too offen/,
|
||||
);
|
||||
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(invalidations, 0);
|
||||
assert.equal(abandoned, 1);
|
||||
assert.equal(abandonedSettled, 0);
|
||||
|
||||
const lateStream = new EventEmitter();
|
||||
lateStream.closed = 0;
|
||||
lateStream.close = () => { lateStream.closed += 1; };
|
||||
lateCallback(null, lateStream);
|
||||
assert.equal(lateStream.closed, 1);
|
||||
assert.equal(abandonedSettled, 1);
|
||||
});
|
||||
|
||||
test("detects bastion channelOpen rate-limit errors including the offen typo", () => {
|
||||
assert.equal(
|
||||
isSshChannelOpenRateLimitedError(
|
||||
@@ -265,89 +151,7 @@ test("shell open retries bastion rate-limit failures with short backoff", async
|
||||
assert.deepEqual(delays, [5, 10]);
|
||||
});
|
||||
|
||||
test("shell open can use a bounded retry window for variable bastion cooldowns", async () => {
|
||||
const delays = [];
|
||||
let elapsedMs = 0;
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
if (attempts <= 4) {
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
return;
|
||||
}
|
||||
next(null, new EventEmitter());
|
||||
},
|
||||
};
|
||||
|
||||
const stream = await openBoundedSshShell(client, {}, {}, {
|
||||
rateLimitRetryTimeoutMs: 11,
|
||||
rateLimitBackoffMs: 1,
|
||||
nowFn: () => elapsedMs,
|
||||
sleepFn: async (ms) => {
|
||||
delays.push(ms);
|
||||
elapsedMs += ms;
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(stream);
|
||||
assert.equal(attempts, 5);
|
||||
assert.deepEqual(delays, [1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test("shell open stops retrying when the bastion retry window is exhausted", async () => {
|
||||
const delays = [];
|
||||
let elapsedMs = 0;
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSshShell(client, {}, {}, {
|
||||
rateLimitRetryTimeoutMs: 5,
|
||||
rateLimitBackoffMs: 2,
|
||||
nowFn: () => elapsedMs,
|
||||
sleepFn: async (ms) => {
|
||||
delays.push(ms);
|
||||
elapsedMs += ms;
|
||||
},
|
||||
}),
|
||||
/channelOpen too offen/,
|
||||
);
|
||||
|
||||
assert.equal(attempts, 2);
|
||||
assert.deepEqual(delays, [2]);
|
||||
});
|
||||
|
||||
test("shell open does not start another attempt after a delayed retry wakes past the deadline", async () => {
|
||||
let elapsedMs = 0;
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSshShell(client, {}, {}, {
|
||||
rateLimitRetryTimeoutMs: 5,
|
||||
rateLimitBackoffMs: 1,
|
||||
nowFn: () => elapsedMs,
|
||||
sleepFn: async () => { elapsedMs = 10; },
|
||||
}),
|
||||
/channelOpen too offen/,
|
||||
);
|
||||
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("shell open does not retry unrelated channel open failures", async () => {
|
||||
const delays = [];
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
@@ -358,12 +162,11 @@ test("shell open does not retry unrelated channel open failures", async () => {
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSshShell(client, {}, {}, {
|
||||
rateLimitRetryTimeoutMs: 10,
|
||||
rateLimitRetries: 3,
|
||||
rateLimitBackoffMs: 5,
|
||||
sleepFn: async (ms) => { delays.push(ms); },
|
||||
sleepFn: async () => {},
|
||||
}),
|
||||
/administratively prohibited/,
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
});
|
||||
@@ -2,7 +2,6 @@ const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const Module = require("node:module");
|
||||
const { abortPendingBoot } = require("./sessionBootEpoch.cjs");
|
||||
|
||||
const sshConnectionPool = require("./sshConnectionPool.cjs");
|
||||
const {
|
||||
@@ -1303,316 +1302,6 @@ test("Copy Tab retries bastion channelOpen too offen before falling back", async
|
||||
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
|
||||
});
|
||||
|
||||
test("Copy Tab keeps reusing when a bastion rate limit outlasts the legacy retry burst", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
if (shellAttempts <= 4) {
|
||||
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
|
||||
return;
|
||||
}
|
||||
const stream = makeStream();
|
||||
sourceConn.openedShells.push(stream);
|
||||
setImmediate(() => cb(null, stream));
|
||||
};
|
||||
sessions.set("source", makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
}));
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const result = await start(
|
||||
{ sender },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
sshChannelOpenRateLimitBackoffMs: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.sessionId, "copy");
|
||||
assert.equal(shellAttempts, 5);
|
||||
assert.equal(getClientConstructCount(), 0);
|
||||
assert.equal(sourceConn.openedShells.length, 1);
|
||||
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
|
||||
});
|
||||
|
||||
test("ordinary parked reuse keeps the legacy retry bound", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
|
||||
};
|
||||
sessions.set("source", makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
}));
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "ordinary",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sshChannelOpenRateLimitBackoffMs: 1,
|
||||
},
|
||||
),
|
||||
/unexpected fresh connect/,
|
||||
);
|
||||
|
||||
// The ordinary path can try the same live transport through its parked and
|
||||
// coordinated-reuse stages. Each stage keeps the legacy four-attempt bound.
|
||||
assert.equal(shellAttempts, 8);
|
||||
assert.equal(getClientConstructCount(), 1);
|
||||
});
|
||||
|
||||
test("cancelling Copy Tab during rate-limit backoff stops retries and keeps the source alive", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
setImmediate(() => {
|
||||
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
setImmediate(() => abortPendingBoot("copy", 1));
|
||||
});
|
||||
};
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
});
|
||||
sessions.set("source", source);
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
bootEpoch: 1,
|
||||
sshChannelOpenRateLimitBackoffMs: 50,
|
||||
},
|
||||
),
|
||||
/aborted/,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
|
||||
assert.equal(shellAttempts, 1);
|
||||
assert.equal(getClientConstructCount(), 0);
|
||||
assert.equal(sourceConn.ended, 0);
|
||||
assert.equal(source.connRef.count, 1);
|
||||
});
|
||||
|
||||
test("cancelling Copy Tab during reused-shell liveness does not register a stale session", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
sourceConn._remoteVer = "CustomBastion_1.0";
|
||||
let openedStream = null;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
openedStream = makeStream();
|
||||
sourceConn.openedShells.push(openedStream);
|
||||
setImmediate(() => {
|
||||
cb(null, openedStream);
|
||||
setTimeout(() => abortPendingBoot("copy", 1), 5);
|
||||
});
|
||||
};
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
});
|
||||
source.connRef.pendingShellReconnectRisk = {
|
||||
oldShellPids: [],
|
||||
hasUnknownOldShell: true,
|
||||
};
|
||||
sessions.set("source", source);
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
bootEpoch: 1,
|
||||
sshReusedShellLivenessMs: 100,
|
||||
},
|
||||
),
|
||||
/aborted/,
|
||||
);
|
||||
|
||||
assert.equal(getClientConstructCount(), 0);
|
||||
assert.equal(sessions.has("copy"), false);
|
||||
assert.equal(openedStream.closed, true);
|
||||
assert.equal(sourceConn.ended, 0);
|
||||
assert.equal(source.connRef.count, 1);
|
||||
});
|
||||
|
||||
test("cancelling ordinary parked reuse does not start a fresh login", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
setImmediate(() => {
|
||||
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
setImmediate(() => abortPendingBoot("ordinary", 1));
|
||||
});
|
||||
};
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
});
|
||||
sessions.set("source", source);
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "ordinary",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
bootEpoch: 1,
|
||||
sshChannelOpenRateLimitBackoffMs: 50,
|
||||
},
|
||||
),
|
||||
/aborted/,
|
||||
);
|
||||
|
||||
assert.equal(shellAttempts, 1);
|
||||
assert.equal(getClientConstructCount(), 0);
|
||||
assert.equal(sourceConn.ended, 0);
|
||||
assert.equal(source.connRef.count, 1);
|
||||
});
|
||||
|
||||
test("an abandoned Copy Tab open blocks overlapping reuse until the raw callback settles", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeDeferredShellConn();
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
});
|
||||
sessions.set("source", source);
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
|
||||
const firstStart = start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy-1",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
bootEpoch: 1,
|
||||
},
|
||||
);
|
||||
for (let attempt = 0; attempt < 20 && sourceConn._pending.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(sourceConn._pending.length, 1);
|
||||
abortPendingBoot("copy-1", 1);
|
||||
await assert.rejects(firstStart, /aborted/);
|
||||
assert.equal(source.connRef.pendingAbandonedShellOpens, 1);
|
||||
assert.equal(sourceConn._pending.length, 1);
|
||||
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy-2",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
bootEpoch: 1,
|
||||
},
|
||||
),
|
||||
/unexpected fresh connect/,
|
||||
);
|
||||
assert.equal(sourceConn._pending.length, 1, "second copy must not overlap the abandoned open");
|
||||
|
||||
sourceConn.flushShell();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(source.connRef.pendingAbandonedShellOpens, undefined);
|
||||
|
||||
const thirdStart = start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy-3",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
bootEpoch: 1,
|
||||
skipShellPidDiscovery: true,
|
||||
},
|
||||
);
|
||||
for (let attempt = 0; attempt < 20 && sourceConn._pending.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(sourceConn._pending.length, 1);
|
||||
sourceConn.flushShell();
|
||||
const result = await thirdStart;
|
||||
|
||||
assert.equal(result.sessionId, "copy-3");
|
||||
assert.equal(getClientConstructCount(), 1);
|
||||
assert.equal(sourceConn.ended, 0);
|
||||
});
|
||||
|
||||
test("a shared connection error during Copy Tab backoff stops queued retries", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
setImmediate(() => {
|
||||
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
setImmediate(() => sourceConn.emit("error", new Error("transport lost")));
|
||||
});
|
||||
};
|
||||
sessions.set("source", makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
}));
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await assert.rejects(
|
||||
start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
sshChannelOpenRateLimitBackoffMs: 50,
|
||||
},
|
||||
),
|
||||
/unexpected fresh connect/,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
|
||||
assert.equal(shellAttempts, 1);
|
||||
assert.equal(getClientConstructCount(), 1);
|
||||
});
|
||||
|
||||
test("Copy Tab opens the shell before PID discovery so bastion rate limits do not burn the session slot", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
|
||||
@@ -30,7 +30,6 @@ const {
|
||||
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
|
||||
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
|
||||
const MAX_SSH_CONNECTION_TIMEOUT_MS = 3600000;
|
||||
const COPY_TAB_RATE_LIMIT_RETRY_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Fan out netcatty:exit to the primary contents plus any attach-home owner
|
||||
@@ -713,41 +712,10 @@ function createStartSessionApi(ctx) {
|
||||
// is restored.
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let onConnError = null;
|
||||
let abortOpenedStream = null;
|
||||
const retryAbortController = new AbortController();
|
||||
const pendingBootSignal = options._passphraseSignal || null;
|
||||
const abortRetryFromPendingBoot = () => {
|
||||
retryAbortController.abort(
|
||||
pendingBootSignal?.reason || new Error("SSH session start was cancelled"),
|
||||
);
|
||||
abortOpenedStream?.(
|
||||
pendingBootSignal?.reason || new Error("SSH session start was cancelled"),
|
||||
);
|
||||
};
|
||||
const cleanupConnectionGuard = () => {
|
||||
if (onConnError) conn.removeListener("error", onConnError);
|
||||
};
|
||||
const cleanupPendingBootGuard = () => {
|
||||
abortOpenedStream = null;
|
||||
pendingBootSignal?.removeEventListener?.("abort", abortRetryFromPendingBoot);
|
||||
};
|
||||
const cleanupReuseGuards = () => {
|
||||
cleanupConnectionGuard();
|
||||
cleanupPendingBootGuard();
|
||||
};
|
||||
|
||||
if (pendingBootSignal?.aborted) {
|
||||
abortRetryFromPendingBoot();
|
||||
} else {
|
||||
pendingBootSignal?.addEventListener?.("abort", abortRetryFromPendingBoot, { once: true });
|
||||
}
|
||||
|
||||
const failReuse = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupReuseGuards();
|
||||
retryAbortController.abort(err);
|
||||
// Release the hold we took up-front so the source's reference count is
|
||||
// not leaked when we fall back to a fresh connection.
|
||||
releaseConnectionRef(refHolder);
|
||||
@@ -759,15 +727,12 @@ function createStartSessionApi(ctx) {
|
||||
// connection. Removed once the channel opens so we don't leave a stray
|
||||
// listener on the shared connection (the owner's own error handler stays
|
||||
// responsible thereafter).
|
||||
onConnError = (connErr) => {
|
||||
const onConnError = (connErr) => {
|
||||
failReuse(connErr);
|
||||
};
|
||||
conn.once("error", onConnError);
|
||||
|
||||
if (
|
||||
connRef.allowShellReuse === false
|
||||
|| Number(connRef.pendingAbandonedShellOpens) > 0
|
||||
) {
|
||||
if (connRef.allowShellReuse === false) {
|
||||
conn.removeListener("error", onConnError);
|
||||
failReuse(new Error("Transport is no longer reusable for shells"));
|
||||
return;
|
||||
@@ -784,7 +749,7 @@ function createStartSessionApi(ctx) {
|
||||
},
|
||||
shellOptions,
|
||||
(err, stream) => {
|
||||
cleanupConnectionGuard();
|
||||
conn.removeListener("error", onConnError);
|
||||
if (settled) {
|
||||
// Connection already failed; close any channel that still opened
|
||||
// and drop the hold (failReuse already released, so guard with the
|
||||
@@ -806,19 +771,7 @@ function createStartSessionApi(ctx) {
|
||||
|
||||
sendProgress('connected');
|
||||
|
||||
abortOpenedStream = (abortError) => {
|
||||
try { stream.close(); } catch { /* ignore */ }
|
||||
failReuse(abortError);
|
||||
};
|
||||
if (pendingBootSignal?.aborted) {
|
||||
abortOpenedStream(
|
||||
pendingBootSignal.reason || new Error("SSH session start was cancelled"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const finishReusedShellOpen = (prefetchedChunks = []) => {
|
||||
cleanupPendingBootGuard();
|
||||
if (settled) {
|
||||
if (stream) { try { stream.close(); } catch { /* ignore */ } }
|
||||
return;
|
||||
@@ -1069,36 +1022,16 @@ function createStartSessionApi(ctx) {
|
||||
failReuse(livenessErr);
|
||||
});
|
||||
},
|
||||
{
|
||||
...(Number.isFinite(rateLimitBackoffMs) && rateLimitBackoffMs > 0
|
||||
? { rateLimitBackoffMs }
|
||||
: {}),
|
||||
...(options.sourceSessionId
|
||||
? { rateLimitRetryTimeoutMs: COPY_TAB_RATE_LIMIT_RETRY_TIMEOUT_MS }
|
||||
: {}),
|
||||
signal: retryAbortController.signal,
|
||||
// Cancelling one pending Copy Tab must not destroy the source
|
||||
// tab's shared authenticated transport.
|
||||
invalidateOnAbort: false,
|
||||
onAbandonedOpen: () => {
|
||||
connRef.pendingAbandonedShellOpens =
|
||||
(Number(connRef.pendingAbandonedShellOpens) || 0) + 1;
|
||||
},
|
||||
onAbandonedOpenSettled: () => {
|
||||
const pending = Math.max(
|
||||
0,
|
||||
(Number(connRef.pendingAbandonedShellOpens) || 0) - 1,
|
||||
);
|
||||
if (pending === 0) delete connRef.pendingAbandonedShellOpens;
|
||||
else connRef.pendingAbandonedShellOpens = pending;
|
||||
},
|
||||
},
|
||||
Number.isFinite(rateLimitBackoffMs) && rateLimitBackoffMs > 0
|
||||
? { rateLimitBackoffMs }
|
||||
: {},
|
||||
);
|
||||
} catch (syncErr) {
|
||||
// ssh2 can throw synchronously (e.g. "Not connected") if the borrowed
|
||||
// transport dropped between findReusableSession and conn.shell(). Make
|
||||
// sure we drop the listener and release the up-front ref so the count
|
||||
// isn't leaked, then fall back to a fresh connection.
|
||||
conn.removeListener("error", onConnError);
|
||||
log("reused shell threw synchronously", { sessionId, hostname: options.hostname, error: syncErr?.message });
|
||||
failReuse(syncErr);
|
||||
}
|
||||
@@ -1194,7 +1127,6 @@ function createStartSessionApi(ctx) {
|
||||
try {
|
||||
return await reuseShellSession(event, options, sourceSession, sessionId, log);
|
||||
} catch (reuseErr) {
|
||||
if (options._passphraseSignal?.aborted) throw reuseErr;
|
||||
log("connection reuse failed, falling back to fresh connection", {
|
||||
sessionId,
|
||||
sourceSessionId: options.sourceSessionId,
|
||||
@@ -1246,11 +1178,6 @@ function createStartSessionApi(ctx) {
|
||||
{ confirmReusedShellLiveness },
|
||||
);
|
||||
} catch (parkErr) {
|
||||
if (options._passphraseSignal?.aborted) {
|
||||
throw options._passphraseSignal.reason instanceof Error
|
||||
? options._passphraseSignal.reason
|
||||
: parkErr;
|
||||
}
|
||||
log("parked transport reuse failed, falling back to fresh connection", {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
@@ -1286,11 +1213,6 @@ function createStartSessionApi(ctx) {
|
||||
log,
|
||||
);
|
||||
} catch (coordinationErr) {
|
||||
if (options._passphraseSignal?.aborted) {
|
||||
throw options._passphraseSignal.reason instanceof Error
|
||||
? options._passphraseSignal.reason
|
||||
: coordinationErr;
|
||||
}
|
||||
// A waiter must observe the leader's real failure instead of
|
||||
// immediately starting a second authentication prompt. Existing
|
||||
// transport reuse keeps its historical fresh-dial fallback.
|
||||
@@ -1309,12 +1231,6 @@ function createStartSessionApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options._passphraseSignal?.aborted) {
|
||||
throw options._passphraseSignal.reason instanceof Error
|
||||
? options._passphraseSignal.reason
|
||||
: new Error("SSH session start was cancelled");
|
||||
}
|
||||
|
||||
const cols = options.cols || 80;
|
||||
const rows = options.rows || 24;
|
||||
|
||||
|
||||
@@ -308,84 +308,8 @@ test("startMoshSession writes the saved password when ssh prompts for one", asyn
|
||||
);
|
||||
|
||||
h.spawns[0].emitData("(alice@example.com) Password:");
|
||||
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
|
||||
|
||||
assert.deepEqual(h.spawns[0].writes, ["saved-secret\r"]);
|
||||
const prompt = h.sent.find((evt) =>
|
||||
evt.channel === "netcatty:data" && evt.payload?.data?.includes("Password:"),
|
||||
);
|
||||
assert.ok(prompt, "expected the saved-password prompt to reach the renderer");
|
||||
assert.notEqual(prompt.payload.meta?.moshHandshakeRequiresUserInput, true);
|
||||
});
|
||||
|
||||
test("startMoshSession marks a password prompt that has no saved answer as interactive", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
|
||||
|
||||
h.spawns[0].emitData("(alice@example.com) Password:");
|
||||
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
|
||||
|
||||
assert.deepEqual(h.spawns[0].writes, []);
|
||||
const prompt = h.sent.find((evt) =>
|
||||
evt.channel === "netcatty:data" && evt.payload?.data?.includes("Password:"),
|
||||
);
|
||||
assert.ok(prompt, "expected the manual password prompt to reach the renderer");
|
||||
assert.equal(prompt.payload.meta?.moshHandshakeRequiresUserInput, true);
|
||||
});
|
||||
|
||||
test("startMoshSession keeps a saved primary password out of an MFA prompt", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{ ...h.options, password: "saved-secret", requiresMfa: true },
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
|
||||
h.spawns[0].emitData("Verification code:");
|
||||
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
|
||||
|
||||
assert.deepEqual(h.spawns[0].writes, []);
|
||||
const prompt = h.sent.find((evt) =>
|
||||
evt.channel === "netcatty:data" && evt.payload?.data?.includes("Verification code:"),
|
||||
);
|
||||
assert.ok(prompt, "expected the MFA prompt to reach the renderer");
|
||||
assert.equal(prompt.payload.meta?.moshHandshakeRequiresUserInput, true);
|
||||
});
|
||||
|
||||
test("startMoshSession marks the OpenSSH first-host confirmation as interactive", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
|
||||
|
||||
h.spawns[0].emitData(
|
||||
"Are you sure you want to continue connecting (yes/no/[fingerprint])?",
|
||||
);
|
||||
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
|
||||
|
||||
const prompt = h.sent.find((evt) =>
|
||||
evt.channel === "netcatty:data" && evt.payload?.data?.includes("continue connecting"),
|
||||
);
|
||||
assert.ok(prompt, "expected the host confirmation prompt to reach the renderer");
|
||||
assert.equal(prompt.payload.meta?.moshHandshakeRequiresUserInput, true);
|
||||
});
|
||||
|
||||
test("startMoshSession exposes shared fail-closed keyboard-interactive prompts", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
|
||||
const prompts = [
|
||||
"Duo two-factor login\r\nPasscode or option (1-1):",
|
||||
"\r\nCustom authentication>",
|
||||
"\r\n验证码:",
|
||||
];
|
||||
|
||||
for (const text of prompts) {
|
||||
h.spawns[0].emitData(text);
|
||||
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
|
||||
const prompt = h.sent.find((evt) =>
|
||||
evt.channel === "netcatty:data" && evt.payload?.data?.includes(text.trim()),
|
||||
);
|
||||
assert.ok(prompt, `expected prompt to reach the renderer: ${text}`);
|
||||
assert.equal(prompt.payload.meta?.moshHandshakeRequiresUserInput, true, text);
|
||||
}
|
||||
});
|
||||
|
||||
test("startMoshSession password-only mode disables public-key authentication", async (t) => {
|
||||
@@ -586,14 +510,8 @@ test("startMoshSession writes the saved passphrase when ssh prompts for the temp
|
||||
);
|
||||
|
||||
h.spawns[0].emitData("Enter passphrase for key 'mosh-auth-key-1.pem':");
|
||||
await new Promise((resolve) => h.sessions.get("mosh-test-session").flushPendingData(resolve));
|
||||
|
||||
assert.deepEqual(h.spawns[0].writes, ["key-passphrase\r"]);
|
||||
const prompt = h.sent.find((evt) =>
|
||||
evt.channel === "netcatty:data" && evt.payload?.data?.includes("passphrase"),
|
||||
);
|
||||
assert.ok(prompt, "expected the saved-passphrase prompt to reach the renderer");
|
||||
assert.notEqual(prompt.payload.meta?.moshHandshakeRequiresUserInput, true);
|
||||
});
|
||||
|
||||
test("startMoshSession swaps to mosh-client when MOSH CONNECT has no trailing newline", async (t) => {
|
||||
@@ -696,7 +614,6 @@ test("startMoshSession tags handshake output and emits ready after mosh-client s
|
||||
);
|
||||
assert.ok(handshakeData, "expected handshake data on netcatty:data");
|
||||
assert.equal(handshakeData.payload.meta?.moshHandshake, true);
|
||||
assert.notEqual(handshakeData.payload.meta?.moshHandshakeRequiresUserInput, true);
|
||||
|
||||
assert.equal(
|
||||
h.sent.some((evt) => evt.channel === "netcatty:mosh:ready"),
|
||||
|
||||
@@ -10,9 +10,6 @@ const {
|
||||
const { createSshConnExecProbe } = require("../ai/sessionShellKind.cjs");
|
||||
const { orderSshIdentityNames, SSH_KEY_PATTERN } = require("../sshAuthHelper.cjs");
|
||||
const { fanoutSessionExit } = require("../terminalAttachRestore.cjs");
|
||||
const {
|
||||
isUntrustedTerminalInputPrompt,
|
||||
} = require("../../../domain/terminalPromptSecurity.shared.cjs");
|
||||
const {
|
||||
buildAuthoritativeKnownHostsContent,
|
||||
buildExternalHostKeySshOptions,
|
||||
@@ -81,46 +78,36 @@ function createMoshSessionApi(ctx) {
|
||||
function isMoshPasswordPrompt(tail) {
|
||||
return /(^|[\r\n]).*password:\s*$/i.test(stripMoshPromptControls(tail));
|
||||
}
|
||||
|
||||
function isMoshHostKeyConfirmationPrompt(tail) {
|
||||
return /are you sure you want to continue connecting \(yes\/no(?:\/\[fingerprint\])?\)\?\s*$/i
|
||||
.test(stripMoshPromptControls(tail));
|
||||
}
|
||||
|
||||
function isMoshMfaPrompt(tail) {
|
||||
return /(?:verification code|one[- ]time (?:password|code)|otp|passcode|authentication code|duo passcode|token):\s*$/i
|
||||
.test(stripMoshPromptControls(tail));
|
||||
}
|
||||
|
||||
function createMoshSshPasswordResponder(sshPty, password, passphrase) {
|
||||
if (
|
||||
(typeof password !== "string" || password.length === 0) &&
|
||||
(typeof passphrase !== "string" || passphrase.length === 0)
|
||||
) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let answeredPassword = false;
|
||||
let answeredPassphrase = false;
|
||||
let tail = "";
|
||||
|
||||
return (chunk) => {
|
||||
if (answeredPassword && answeredPassphrase) return;
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || "");
|
||||
if (!text) return false;
|
||||
if (!text) return;
|
||||
|
||||
tail = (tail + text).slice(-512);
|
||||
const passphrasePrompt = isMoshPassphrasePrompt(tail);
|
||||
const mfaPrompt = isMoshMfaPrompt(tail);
|
||||
const passwordPrompt = !mfaPrompt && isMoshPasswordPrompt(tail);
|
||||
if (typeof passphrase === "string" && passphrase.length > 0 && !answeredPassphrase && passphrasePrompt) {
|
||||
if (typeof passphrase === "string" && passphrase.length > 0 && !answeredPassphrase && isMoshPassphrasePrompt(tail)) {
|
||||
answeredPassphrase = true;
|
||||
sshPty.write(`${passphrase}\r`);
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof password === "string" && password.length > 0 && !answeredPassword && passwordPrompt) {
|
||||
answeredPassword = true;
|
||||
sshPty.write(`${password}\r`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return passphrasePrompt
|
||||
|| passwordPrompt
|
||||
|| isMoshHostKeyConfirmationPrompt(tail)
|
||||
|| isUntrustedTerminalInputPrompt(tail);
|
||||
if (typeof password !== "string" || password.length === 0 || answeredPassword) return;
|
||||
if (!isMoshPasswordPrompt(tail)) return;
|
||||
|
||||
answeredPassword = true;
|
||||
sshPty.write(`${password}\r`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -626,11 +613,8 @@ function createMoshSessionApi(ctx) {
|
||||
if (visible && (visible.length || (typeof visible === "string" && visible))) {
|
||||
const str = Buffer.isBuffer(visible) ? visible.toString("utf8") : visible;
|
||||
if (str.length > 0) {
|
||||
const requiresUserInput = respondToPasswordPrompt(str);
|
||||
bufferData(
|
||||
str,
|
||||
requiresUserInput ? { moshHandshakeRequiresUserInput: true } : undefined,
|
||||
);
|
||||
respondToPasswordPrompt(str);
|
||||
bufferData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,12 +438,12 @@ function closeBrowserWindow(win) {
|
||||
}
|
||||
}
|
||||
|
||||
function requestWindowCommandClose(win) {
|
||||
function requestWindowCommandClose(win, request) {
|
||||
if (!win || win.isDestroyed?.()) return false;
|
||||
try {
|
||||
const webContents = win.webContents;
|
||||
if (!webContents || webContents.isDestroyed?.()) return closeBrowserWindow(win);
|
||||
webContents.send(WINDOW_COMMAND_CLOSE_CHANNEL);
|
||||
webContents.send(WINDOW_COMMAND_CLOSE_CHANNEL, request);
|
||||
return true;
|
||||
} catch {
|
||||
return closeBrowserWindow(win);
|
||||
@@ -1264,7 +1264,7 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage, options = {}
|
||||
? menuDeps.setAppLockWindowTitle
|
||||
: undefined),
|
||||
};
|
||||
const closeFocusedWindow = (_menuItem, browserWindow, event) => {
|
||||
const closeFocusedWindow = (_menuItem, browserWindow) => {
|
||||
// Block native Close while app lock is visible so popups/sessions are not
|
||||
// torn down behind the overlay (Codex P2 on 79603979).
|
||||
try {
|
||||
@@ -1278,14 +1278,6 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage, options = {}
|
||||
return;
|
||||
}
|
||||
|
||||
// Selecting Close Window with the mouse remains an explicit window-close
|
||||
// action. Only the menu accelerator is routed through the configurable
|
||||
// close-tab shortcut in the renderer.
|
||||
if (event?.triggeredByAccelerator === false) {
|
||||
closeBrowserWindow(browserWindow || getMainWindow());
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS 的 Cmd+W 先交给渲染层关闭标签页;没有标签页时渲染层再关闭窗口。
|
||||
requestWindowCommandClose(browserWindow) || requestWindowCommandClose(getMainWindow());
|
||||
};
|
||||
|
||||
@@ -275,8 +275,10 @@ function createMainWindowApi(ctx) {
|
||||
// while preventing Chromium's built-in shortcuts from triggering.
|
||||
win.webContents.on("before-input-event", (event, input) => {
|
||||
if (isMac && shouldCloseWindowFromInput(input)) {
|
||||
event.preventDefault();
|
||||
requestWindowCommandClose(win);
|
||||
// Keep the app menu accelerator from consuming Cmd+W, but preserve
|
||||
// the real DOM key event so the current renderer binding can route it
|
||||
// through app, terminal, editor, or SFTP shortcut handlers.
|
||||
win.webContents.setIgnoreMenuShortcuts(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -358,9 +358,6 @@ test("buildAppMenu sends Cmd+W to any registered main window renderer", () => {
|
||||
const firstMainWindow = {
|
||||
isDestroyed() { return false; },
|
||||
on() {},
|
||||
close() {
|
||||
calls.push("first:close");
|
||||
},
|
||||
webContents: {
|
||||
isDestroyed() { return false; },
|
||||
send(channel) {
|
||||
@@ -386,16 +383,9 @@ test("buildAppMenu sends Cmd+W to any registered main window renderer", () => {
|
||||
const windowMenu = capturedTemplate.find((item) => item.label === "Window");
|
||||
const closeItem = windowMenu.submenu.find((item) => item.accelerator === "CommandOrControl+W");
|
||||
|
||||
closeItem.click(null, firstMainWindow, { triggeredByAccelerator: true });
|
||||
closeItem.click(null, firstMainWindow);
|
||||
|
||||
assert.deepEqual(calls, ["first:netcatty:window:command-close"]);
|
||||
|
||||
closeItem.click(null, firstMainWindow, { triggeredByAccelerator: false });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"first:netcatty:window:command-close",
|
||||
"first:close",
|
||||
]);
|
||||
} finally {
|
||||
unregisterMainWindow(firstMainWindow);
|
||||
unregisterMainWindow(secondMainWindow);
|
||||
@@ -494,19 +484,24 @@ test("buildAppMenu renders localized plugin commands with checked and disabled s
|
||||
});
|
||||
|
||||
test("requestWindowCommandClose sends command-close to renderer-capable windows", () => {
|
||||
const sentChannels = [];
|
||||
const sentMessages = [];
|
||||
const win = {
|
||||
isDestroyed() { return false; },
|
||||
webContents: {
|
||||
isDestroyed() { return false; },
|
||||
send(channel) {
|
||||
sentChannels.push(channel);
|
||||
send(channel, request) {
|
||||
sentMessages.push({ channel, request });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(requestWindowCommandClose(win), true);
|
||||
assert.deepEqual(sentChannels, ["netcatty:window:command-close"]);
|
||||
const keyboardRequest = { source: "keyboard", input: { key: "w" } };
|
||||
assert.equal(requestWindowCommandClose(win, keyboardRequest), true);
|
||||
assert.deepEqual(sentMessages, [
|
||||
{ channel: "netcatty:window:command-close", request: undefined },
|
||||
{ channel: "netcatty:window:command-close", request: keyboardRequest },
|
||||
]);
|
||||
});
|
||||
|
||||
test("shouldCloseWindowFromInput only matches macOS Command+W keydown", () => {
|
||||
@@ -517,9 +512,10 @@ test("shouldCloseWindowFromInput only matches macOS Command+W keydown", () => {
|
||||
assert.equal(shouldCloseWindowFromInput({ type: "keyDown", meta: true, shift: true, key: "w" }), false);
|
||||
});
|
||||
|
||||
test("main window asks renderer to close tabs from macOS Command+W before-input-event", async () => {
|
||||
test("main window lets macOS Command+W reach renderer shortcut handlers without invoking the menu", async () => {
|
||||
let beforeInputHandler = null;
|
||||
const commandCloseRequests = [];
|
||||
const ignoreMenuShortcutValues = [];
|
||||
|
||||
class BrowserWindowStub {
|
||||
constructor() {
|
||||
@@ -535,7 +531,9 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
setIgnoreMenuShortcuts() {},
|
||||
setIgnoreMenuShortcuts(value) {
|
||||
ignoreMenuShortcutValues.push(value);
|
||||
},
|
||||
setWindowOpenHandler() {},
|
||||
openDevTools() {},
|
||||
};
|
||||
@@ -590,8 +588,8 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
|
||||
createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {},
|
||||
registerWindowHandlers() {},
|
||||
requestWindowCommandClose(win) {
|
||||
commandCloseRequests.push(win);
|
||||
requestWindowCommandClose(win, request) {
|
||||
commandCloseRequests.push({ win, request });
|
||||
return true;
|
||||
},
|
||||
shouldCloseWindowFromInput,
|
||||
@@ -626,8 +624,9 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
|
||||
key: "w",
|
||||
});
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(commandCloseRequests.length, 1);
|
||||
assert.equal(prevented, false);
|
||||
assert.deepEqual(ignoreMenuShortcutValues, [true]);
|
||||
assert.deepEqual(commandCloseRequests, []);
|
||||
});
|
||||
|
||||
test("main window leaves primary-modifier reload-like shortcuts available to renderer handlers", async () => {
|
||||
|
||||
@@ -1092,7 +1092,7 @@ function createPreloadApi(ctx) {
|
||||
return () => ipcRenderer.removeListener("netcatty:window:openSession", handler);
|
||||
},
|
||||
onWindowCommandCloseRequested: (cb) => {
|
||||
const handler = () => cb();
|
||||
const handler = (_event, request) => cb(request);
|
||||
ipcRenderer.on("netcatty:window:command-close", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:window:command-close", handler);
|
||||
},
|
||||
|
||||
@@ -1250,6 +1250,33 @@ test("onWindowFocusRequested is wired to the focus-requested IPC", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("onWindowCommandCloseRequested forwards the keyboard request payload", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const calls = [];
|
||||
preload.api.onWindowCommandCloseRequested((request) => {
|
||||
calls.push(request);
|
||||
});
|
||||
const request = {
|
||||
source: "keyboard",
|
||||
input: {
|
||||
key: "w",
|
||||
code: "KeyW",
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
},
|
||||
};
|
||||
|
||||
preload.handlers.get("netcatty:window:command-close")?.({}, request);
|
||||
|
||||
assert.deepEqual(calls, [request]);
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("onZmodemEvent unsubscribe removes empty listener set", () => {
|
||||
const zmodemListeners = new Map();
|
||||
const api = createPreloadApi({
|
||||
|
||||
@@ -985,6 +985,83 @@ html[data-theme-transition] *::after {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-content: flex-start !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
align-items: center !important;
|
||||
min-height: 2rem !important;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid hsl(var(--border) / 0.6) !important;
|
||||
border-radius: 6px !important;
|
||||
background: hsl(var(--background) / 0.96) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar button,
|
||||
.netcatty-note-markdown-toolbar [role="button"],
|
||||
.netcatty-note-markdown-toolbar [role="combobox"] {
|
||||
color: hsl(var(--muted-foreground)) !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar button:hover,
|
||||
.netcatty-note-markdown-toolbar [role="button"]:hover,
|
||||
.netcatty-note-markdown-toolbar [role="combobox"]:hover,
|
||||
.netcatty-note-markdown-toolbar button[data-state="on"],
|
||||
.netcatty-note-markdown-toolbar button[data-state="open"] {
|
||||
background: hsl(var(--secondary)) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar [role="separator"] {
|
||||
flex-shrink: 0;
|
||||
background: hsl(var(--border) / 0.7) !important;
|
||||
}
|
||||
|
||||
@container (max-width: 34rem) {
|
||||
.netcatty-note-markdown-toolbar {
|
||||
gap: 0.125rem !important;
|
||||
row-gap: 0.125rem !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-content: flex-start !important;
|
||||
min-height: 1.9rem !important;
|
||||
padding: 0.125rem !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar button,
|
||||
.netcatty-note-markdown-toolbar [role="button"] {
|
||||
width: 1.75rem !important;
|
||||
min-width: 1.75rem !important;
|
||||
height: 1.75rem !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar [role="combobox"] {
|
||||
min-width: 7.25rem !important;
|
||||
max-width: 8.5rem !important;
|
||||
height: 1.75rem !important;
|
||||
padding: 0 0.45rem !important;
|
||||
}
|
||||
|
||||
.netcatty-note-markdown-toolbar [role="separator"] {
|
||||
margin: 0 0.125rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor,
|
||||
.netcatty-mdx-editor .mdxeditor-root-contenteditable {
|
||||
min-height: 100%;
|
||||
@@ -1176,10 +1253,7 @@ html[data-theme-transition] *::after {
|
||||
min-height: calc(100vh - 15rem);
|
||||
padding: 0;
|
||||
color: hsl(var(--foreground) / 0.9);
|
||||
/* Note body follows the UI font (Settings > Appearance > Interface Font);
|
||||
the note font tool only controls code block / inline code fonts. */
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--note-font-size, 15px);
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
outline: none;
|
||||
}
|
||||
@@ -1239,30 +1313,6 @@ html[data-theme-transition] *::after {
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ul ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content ol ol ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
@@ -1274,11 +1324,8 @@ html[data-theme-transition] *::after {
|
||||
* margin so label text lines up cleanly with the control.
|
||||
*/
|
||||
.netcatty-mdx-content ul:has(> li[role="checkbox"]),
|
||||
.netcatty-mdx-content ul:has(> li[aria-checked]),
|
||||
.netcatty-mdx-content ul:has(> li[class*="_listItemChecked_"]),
|
||||
.netcatty-mdx-content ul:has(> li[class*="_listItemUnchecked_"]) {
|
||||
list-style: none !important;
|
||||
list-style-type: none !important;
|
||||
.netcatty-mdx-content ul:has(> li[aria-checked]) {
|
||||
list-style: none;
|
||||
padding-left: 0.15rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -1350,155 +1397,71 @@ html[data-theme-transition] *::after {
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--secondary) / 0.8);
|
||||
padding: 0.12rem 0.3rem;
|
||||
font-family: var(--note-font-family, var(--font-mono));
|
||||
font-size: var(--note-code-font-size, 0.88em);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.86em;
|
||||
}
|
||||
|
||||
.netcatty-mdx-content pre {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--secondary) / 0.35);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorWrapper_"] {
|
||||
position: relative !important;
|
||||
min-height: 2rem !important;
|
||||
margin: 0.35rem 0 0.65rem;
|
||||
position: relative;
|
||||
margin: 0.85rem 0 1rem;
|
||||
border: 0 !important;
|
||||
border-radius: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
overflow: visible !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.netcatty-note-code-copy {
|
||||
position: absolute !important;
|
||||
top: 0.2rem !important;
|
||||
right: 0.15rem !important;
|
||||
z-index: 40 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 1.45rem !important;
|
||||
height: 1.45rem !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: transparent !important;
|
||||
color: hsl(var(--muted-foreground)) !important;
|
||||
box-shadow: none !important;
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
z-index: 20;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.45rem;
|
||||
padding: 0 0.45rem;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--background) / 0.92);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
cursor: pointer !important;
|
||||
transition: opacity 0.15s, color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorWrapper_"]:hover .netcatty-note-code-copy,
|
||||
.netcatty-mdx-editor .cm-editor:hover ~ .netcatty-note-code-copy,
|
||||
.netcatty-mdx-editor pre:hover .netcatty-note-code-copy,
|
||||
.netcatty-mdx-content pre:hover .netcatty-note-code-copy,
|
||||
.netcatty-mdx-editor--preview .netcatty-note-code-copy,
|
||||
.netcatty-note-code-copy[data-copied="true"],
|
||||
.netcatty-note-code-copy:focus-visible {
|
||||
opacity: 0.85 !important;
|
||||
pointer-events: auto !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.netcatty-note-code-copy:hover,
|
||||
.netcatty-mdx-editor--preview .netcatty-note-code-copy:hover {
|
||||
opacity: 1 !important;
|
||||
background: hsl(var(--secondary)) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
.netcatty-note-code-copy:hover {
|
||||
background: hsl(var(--secondary));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.netcatty-note-code-copy[data-copied="true"] {
|
||||
color: hsl(var(--foreground)) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor .cm-editor,
|
||||
.netcatty-mdx-editor .cm-content,
|
||||
.netcatty-mdx-editor [class*="_codeMirrorWrapper_"],
|
||||
.netcatty-mdx-editor pre,
|
||||
.netcatty-mdx-editor code,
|
||||
.netcatty-mdx-editor [class*="_code_"],
|
||||
.netcatty-mdx-editor [class*="code_"],
|
||||
.netcatty-mdx-editor kbd,
|
||||
.netcatty-mdx-editor samp,
|
||||
.netcatty-mdx-content pre,
|
||||
.netcatty-mdx-content code,
|
||||
.netcatty-mdx-content [class*="_code_"],
|
||||
.netcatty-mdx-content [class*="code_"],
|
||||
.netcatty-mdx-content kbd,
|
||||
.netcatty-mdx-content samp,
|
||||
.netcatty-source-editor,
|
||||
.netcatty-source-editor textarea {
|
||||
font-family: var(--note-font-family, var(--font-mono)) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor .cm-editor,
|
||||
.netcatty-mdx-editor .cm-content,
|
||||
.netcatty-mdx-editor pre,
|
||||
.netcatty-mdx-content pre,
|
||||
.netcatty-source-editor textarea {
|
||||
font-size: var(--note-code-font-size, 13px) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorWrapper_"]:has([data-language="latex"]),
|
||||
.netcatty-mdx-editor [class*="_codeMirrorWrapper_"]:has([data-language="tex"]) {
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.netcatty-math-formula-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: safe center;
|
||||
padding: 0.55rem 0;
|
||||
margin: 0.4rem 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: 1.45em;
|
||||
color: hsl(var(--foreground));
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
user-select: text;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.netcatty-math-formula-preview .katex-display {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.netcatty-math-formula-preview .katex {
|
||||
font-size: 1em;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.netcatty-math-formula-preview .katex-mathml math {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.netcatty-math-reading-mode .cm-editor {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.netcatty-math-reading-mode {
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] {
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
top: auto !important;
|
||||
z-index: auto !important;
|
||||
gap: 0.125rem;
|
||||
padding: 0.125rem;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0.4rem 0 !important;
|
||||
}
|
||||
|
||||
.netcatty-math-reading-mode .netcatty-math-formula-preview {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.netcatty-math-reading-mode > .netcatty-note-code-copy {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"] {
|
||||
@@ -1506,10 +1469,10 @@ html[data-theme-transition] *::after {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
margin: 0.25rem 0 0.55rem;
|
||||
border-radius: 0;
|
||||
background: transparent !important;
|
||||
overflow: visible !important;
|
||||
margin: 0.3rem 0 0.65rem;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--secondary) / 0.35) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) .netcatty-mdx-content p:has(+ [class*="_codeMirrorWrapper_"]) {
|
||||
@@ -1517,7 +1480,6 @@ html[data-theme-transition] *::after {
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"] .cm-editor {
|
||||
min-height: 2rem !important;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
@@ -1526,82 +1488,65 @@ html[data-theme-transition] *::after {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"] .cm-scroller {
|
||||
padding-right: 4.8rem !important;
|
||||
min-height: 2.2rem !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] {
|
||||
position: absolute !important;
|
||||
top: 0.35rem !important;
|
||||
right: 0.35rem !important;
|
||||
z-index: 30 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
z-index: auto;
|
||||
display: flex !important;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem !important;
|
||||
padding: 0.2rem 0.25rem !important;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
gap: 0.125rem;
|
||||
padding: 0.15rem 0.3rem 0 !important;
|
||||
margin: 0 !important;
|
||||
width: auto !important;
|
||||
border-radius: 6px !important;
|
||||
background: hsl(var(--background) / 0.94) !important;
|
||||
border: 1px solid hsl(var(--border) / 0.65) !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.18) !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
transition: opacity 0.15s ease !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"]:hover [class*="_codeMirrorToolbar_"],
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"]:focus-within [class*="_codeMirrorToolbar_"],
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"]:has([data-state="open"]) {
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Language selector trigger in toolbar */
|
||||
/* Align the TooltipWrap span wrapper so the language select sits level with action buttons */
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_tooltipTrigger_"] {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
align-self: center !important;
|
||||
line-height: 1 !important;
|
||||
.netcatty-mdx-editor--preview [class*="_codeMirrorToolbar_"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"],
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
height: 1.45rem !important;
|
||||
min-height: 1.45rem !important;
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
padding: 0 0.45rem !important;
|
||||
margin: 0 !important;
|
||||
gap: 0.25rem !important;
|
||||
padding: 0 0.4rem !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 500 !important;
|
||||
line-height: 1 !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"],
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start;
|
||||
height: 1.35rem !important;
|
||||
width: auto !important;
|
||||
min-width: fit-content !important;
|
||||
max-width: none !important;
|
||||
padding: 0 0.35rem !important;
|
||||
margin: 0 !important;
|
||||
gap: 0.2rem;
|
||||
font-size: 11px !important;
|
||||
font-weight: 400 !important;
|
||||
line-height: 1 !important;
|
||||
white-space: nowrap;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: hsl(var(--muted) / 0.7) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
background: hsl(var(--background) / 0.88) !important;
|
||||
box-shadow: none !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.15s ease, color 0.15s ease !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"]:hover,
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"]:hover {
|
||||
background: hsl(var(--secondary)) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] [data-radix-select-value],
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] > span:first-child {
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] [data-radix-select-value],
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] > span:first-child {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
max-width: none;
|
||||
@@ -1610,66 +1555,48 @@ html[data-theme-transition] *::after {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectDropdownArrow_"] svg,
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"] svg,
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] svg {
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"][data-state="open"] {
|
||||
border-radius: 4px !important;
|
||||
background: hsl(var(--background) / 0.88) !important;
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectDropdownArrow_"] {
|
||||
margin-left: 0 !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"] svg,
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] [class*="_selectTrigger_"] svg {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Base style for all action buttons (Delete & Copy) in toolbar */
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] button:not([class*="_selectTrigger_"]):not([class*="_toolbarCodeBlockLanguageSelectTrigger_"]) {
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] button {
|
||||
min-height: 1.45rem !important;
|
||||
height: 1.45rem !important;
|
||||
min-width: 1.45rem !important;
|
||||
width: auto !important;
|
||||
padding: 0 0.35rem !important;
|
||||
background: hsl(var(--background) / 0.88) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] button {
|
||||
min-height: 1.35rem !important;
|
||||
height: 1.35rem !important;
|
||||
min-width: 1.35rem !important;
|
||||
width: 1.35rem !important;
|
||||
padding: 0 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
height: 1.4rem !important;
|
||||
min-height: 1.4rem !important;
|
||||
max-height: 1.4rem !important;
|
||||
width: 1.4rem !important;
|
||||
min-width: 1.4rem !important;
|
||||
max-width: 1.4rem !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: transparent !important;
|
||||
color: hsl(var(--muted-foreground)) !important;
|
||||
box-shadow: none !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.15s ease, color 0.15s ease !important;
|
||||
}
|
||||
|
||||
/* Delete Button specific hover */
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] button:not([class*="_selectTrigger_"]):not([class*="_toolbarCodeBlockLanguageSelectTrigger_"]):not(.netcatty-note-code-copy):hover {
|
||||
background: hsl(var(--destructive) / 0.15) !important;
|
||||
color: hsl(var(--destructive)) !important;
|
||||
}
|
||||
|
||||
/* Copy Button inside toolbar */
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] .netcatty-note-code-copy {
|
||||
position: static !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] .netcatty-note-code-copy:hover {
|
||||
background: hsl(160 84% 39% / 0.15) !important;
|
||||
color: hsl(160 84% 39%) !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] button svg {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"]:has([class*="_codeMirrorToolbar_"]) > .netcatty-note-code-copy {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor--preview [class*="_codeMirrorToolbar_"] {
|
||||
display: none !important;
|
||||
.netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorToolbar_"] button svg {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [class*="_codeMirrorToolbar_"] [class*="_toolbarCodeBlockLanguageSelectTrigger_"] span {
|
||||
@@ -1744,8 +1671,8 @@ html[data-theme-transition] *::after {
|
||||
--note-code-token-string: color-mix(in oklab, hsl(var(--primary)) 24%, hsl(var(--foreground)));
|
||||
overflow: hidden;
|
||||
border: 0 !important;
|
||||
border-radius: 0;
|
||||
background: transparent !important;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--secondary) / 0.35) !important;
|
||||
color: hsl(var(--foreground) / 0.9) !important;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px !important;
|
||||
@@ -1761,7 +1688,7 @@ html[data-theme-transition] *::after {
|
||||
background: transparent !important;
|
||||
color: hsl(var(--foreground) / 0.9) !important;
|
||||
font-family: var(--font-mono);
|
||||
padding: 0 !important;
|
||||
padding: 0.25rem 0 !important;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor .cm-line {
|
||||
@@ -1812,7 +1739,7 @@ html[data-theme-transition] *::after {
|
||||
|
||||
.netcatty-mdx-editor .cm-gutters {
|
||||
border-right: 1px solid hsl(var(--border) / 0.35) !important;
|
||||
background: transparent !important;
|
||||
background: hsl(var(--secondary) / 0.24) !important;
|
||||
color: hsl(var(--muted-foreground) / 0.75) !important;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px !important;
|
||||
@@ -1955,47 +1882,16 @@ html[data-theme-transition] *::after {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Image chrome stays out of the image until the image is hovered or focused. */
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"] [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"] [class*="_editImageToolbar_"] {
|
||||
top: 0.375rem;
|
||||
right: 0.375rem;
|
||||
gap: 0.0625rem;
|
||||
padding: 0.125rem;
|
||||
/* Small-icon chrome: trash/settings only on hover. */
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"][data-note-img-size="sm"] [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"][data-note-img-size="sm"] [class*="_editImageToolbar_"] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--background) / 0.96);
|
||||
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.12);
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"] [class*="_editImageToolbar_"] button,
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"] [class*="_editImageToolbar_"] button {
|
||||
display: inline-flex;
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"] [class*="_editImageToolbar_"] button svg,
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"] [class*="_editImageToolbar_"] button svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"] [class*="_editImageToolbar_"] button:hover,
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"] [class*="_editImageToolbar_"] button:hover {
|
||||
background: hsl(var(--secondary) / 0.75);
|
||||
}
|
||||
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"]:hover [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"]:focus-within [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"]:hover [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"]:focus-within [class*="_editImageToolbar_"] {
|
||||
.netcatty-mdx-editor [data-editor-block-type="image"][data-note-img-size="sm"]:hover [class*="_editImageToolbar_"],
|
||||
.netcatty-mdx-editor [class*="_imageWrapper_"][data-note-img-size="sm"]:hover [class*="_editImageToolbar_"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -58,9 +58,6 @@ export const STORAGE_KEY_VAULT_NOTES_VIEW_MODE = 'netcatty_vault_notes_view_mode
|
||||
export const STORAGE_KEY_VAULT_NOTES_EDITOR_MODE = 'netcatty_vault_notes_editor_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_NOTES_SELECTED_GROUP = 'netcatty_vault_notes_selected_group_v1';
|
||||
export const STORAGE_KEY_VAULT_NOTES_TREE_WIDTH = 'netcatty_vault_notes_tree_width_v1';
|
||||
export const STORAGE_KEY_VAULT_NOTES_FONT_FAMILY = 'netcatty_vault_notes_font_family_v1';
|
||||
export const STORAGE_KEY_VAULT_NOTES_FONT_SIZE = 'netcatty_vault_notes_font_size_v1';
|
||||
export const STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE = 'netcatty_vault_notes_code_font_size_v1';
|
||||
/** Inline snippet/script edit panel width (px). */
|
||||
export const STORAGE_KEY_SNIPPETS_PANEL_WIDTH = 'netcatty_snippets_panel_width_v1';
|
||||
/** Inline vault host/group details panel width (px). */
|
||||
|
||||
Generated
-409
@@ -59,12 +59,7 @@
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"katex": "^0.18.4",
|
||||
"lucide-react": "0.560.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-pty": "1.1.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
@@ -107,7 +102,6 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"patch-package": "^8.0.0",
|
||||
"react-test-renderer": "^19.2.4",
|
||||
"sass": "^1.103.1",
|
||||
"simple-icons": "^16.23.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
@@ -4749,326 +4743,6 @@
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz",
|
||||
"integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3",
|
||||
"is-glob": "^4.0.3",
|
||||
"node-addon-api": "^7.0.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher-android-arm64": "2.6.0",
|
||||
"@parcel/watcher-darwin-arm64": "2.6.0",
|
||||
"@parcel/watcher-darwin-x64": "2.6.0",
|
||||
"@parcel/watcher-freebsd-x64": "2.6.0",
|
||||
"@parcel/watcher-linux-arm-glibc": "2.6.0",
|
||||
"@parcel/watcher-linux-arm-musl": "2.6.0",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.6.0",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.6.0",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.6.0",
|
||||
"@parcel/watcher-linux-x64-musl": "2.6.0",
|
||||
"@parcel/watcher-win32-arm64": "2.6.0",
|
||||
"@parcel/watcher-win32-x64": "2.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-android-arm64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
|
||||
"integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
|
||||
"integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-x64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
|
||||
"integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
|
||||
"integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
|
||||
"integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
|
||||
"integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
|
||||
"integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
|
||||
"integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
|
||||
"integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
|
||||
"integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-arm64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
|
||||
"integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-x64": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
|
||||
"integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
|
||||
@@ -10476,22 +10150,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -13369,13 +13027,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
|
||||
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -13944,31 +13595,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.18.4",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.4.tgz",
|
||||
"integrity": "sha512-IMPntbRLOU+eu88XDiFKqQ8Akhr9Tv7jDMXqPhjG9SI1JMA4DIgXk4x9k4skJz2NZJXBRbC+2pYBLj9olqcZow==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/katex/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -17422,20 +17048,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
||||
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
||||
@@ -17843,27 +17455,6 @@
|
||||
"truncate-utf8-bytes": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.103.1",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.103.1.tgz",
|
||||
"integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"immutable": "^5.1.5",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"sass": "sass.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
||||
|
||||
+1
-8
@@ -42,7 +42,7 @@
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
|
||||
"postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"generate:capability-tools": "node scripts/generate-capability-tools.cjs",
|
||||
@@ -65,7 +65,6 @@
|
||||
"test:ssh-mfa-models": "node --test electron/bridges/sshMfaModels.live.test.cjs",
|
||||
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
|
||||
"test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs",
|
||||
"test:xterm-macos-selection": "electron scripts/xterm-macos-column-selection.live.test.cjs",
|
||||
"test:xterm-keyword-highlight-performance": "electron scripts/xterm-keyword-highlight-performance.live.test.cjs",
|
||||
"test:xterm-keyword-highlight-throughput": "electron scripts/xterm-keyword-highlight-throughput.live.test.cjs"
|
||||
},
|
||||
@@ -115,12 +114,7 @@
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"katex": "^0.18.4",
|
||||
"lucide-react": "0.560.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-pty": "1.1.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
@@ -160,7 +154,6 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"patch-package": "^8.0.0",
|
||||
"react-test-renderer": "^19.2.4",
|
||||
"sass": "^1.103.1",
|
||||
"simple-icons": "^16.23.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user