mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5329a47d23 | ||
|
|
162b5a599f |
No files matched your search
@@ -1,25 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
|
||||
isTransferPoolIdleReclaimDisabled,
|
||||
resolveSftpTransferPoolIdleTtlMs,
|
||||
} from "../../../infrastructure/config/sftpTransferPool.ts";
|
||||
|
||||
test("default pool idle TTL is five minutes", () => {
|
||||
assert.equal(DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS, 5 * 60_000);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => null), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
|
||||
});
|
||||
|
||||
test("accepts preset values including never-reclaim zero", () => {
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 60_000), 60_000);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 0), 0);
|
||||
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 123), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
|
||||
});
|
||||
|
||||
test("transfer pool idle reclaim helper is deprecated and always reclaim-enabled", () => {
|
||||
// Channels close when idle; SSH park is separate. The helper always returns false.
|
||||
assert.equal(isTransferPoolIdleReclaimDisabled(0), false);
|
||||
assert.equal(isTransferPoolIdleReclaimDisabled(300_000), false);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
@@ -206,15 +207,7 @@ const createKnownHostFromSftpHostKeyInfo = (
|
||||
hostKeyInfo: SftpHostKeyInfo,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => ({
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
});
|
||||
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
|
||||
@@ -2,25 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export function useWarmSftpTransferPool(params: {
|
||||
hostIds: readonly string[];
|
||||
activeHostId?: string;
|
||||
warmTransferPoolForHost?: (hostId: string) => void | Promise<void>;
|
||||
}) {
|
||||
const warmRef = useRef(params.warmTransferPoolForHost);
|
||||
warmRef.current = params.warmTransferPoolForHost;
|
||||
const hostIdsKey = [...new Set([
|
||||
...params.hostIds,
|
||||
...(params.activeHostId ? [params.activeHostId] : []),
|
||||
])].sort().join("\u0000");
|
||||
|
||||
useEffect(() => {
|
||||
for (const hostId of hostIdsKey.split("\u0000").filter(Boolean)) {
|
||||
void warmRef.current?.(hostId);
|
||||
}
|
||||
}, [hostIdsKey]);
|
||||
}
|
||||
|
||||
export function useReportSftpTransferOwnerActivity(params: {
|
||||
ownerId: string;
|
||||
activeTransfersCount: number;
|
||||
|
||||
@@ -144,12 +144,26 @@ export function readExternalMcpMode(): ExternalMcpMode {
|
||||
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
|
||||
}
|
||||
|
||||
export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode {
|
||||
const normalized = normalizeExternalMcpMode(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpIdleTimeoutMinutes(): number {
|
||||
return normalizeExternalMcpIdleTimeoutMinutes(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number {
|
||||
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
|
||||
export function readExternalMcpFocusOnHostOpen(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
|
||||
@@ -187,6 +201,13 @@ export function readSessionIdleTimeoutMinutes(): number {
|
||||
);
|
||||
}
|
||||
|
||||
export function writeSessionIdleTimeoutMinutes(minutes: number): number {
|
||||
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
|
||||
@@ -399,14 +399,6 @@ export const useSftpState = (
|
||||
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated No-op. SSH transport idle park keeps connections warm; opening
|
||||
* a background transfer channel is unnecessary and could re-trigger MFA.
|
||||
*/
|
||||
const warmTransferPoolForHost = useCallback(async (_hostId: string) => {
|
||||
// Intentionally empty — unified transport registry owns keep-alive.
|
||||
}, []);
|
||||
|
||||
/** True after browse channels were soft-closed while this owner stayed mounted. */
|
||||
const browseParkedRef = useRef(false);
|
||||
const browseLifecycleGenRef = useRef(0);
|
||||
@@ -824,7 +816,6 @@ export const useSftpState = (
|
||||
rejectHostKeyVerification,
|
||||
acceptHostKeyVerification,
|
||||
acceptAndSaveHostKeyVerification,
|
||||
warmTransferPoolForHost,
|
||||
};
|
||||
const methodsRef = useRef(currentMethods);
|
||||
methodsRef.current = currentMethods;
|
||||
@@ -907,8 +898,6 @@ export const useSftpState = (
|
||||
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
|
||||
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
|
||||
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
|
||||
warmTransferPoolForHost: (...args: Parameters<typeof warmTransferPoolForHost>) =>
|
||||
methodsRef.current.warmTransferPoolForHost(...args),
|
||||
activeFileWatchCountRef,
|
||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import {
|
||||
useReportSftpTransferOwnerActivity,
|
||||
useWarmSftpTransferPool,
|
||||
} from "../application/state/sftp/useSftpTransferLifecycle";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import {
|
||||
@@ -300,12 +299,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
useWarmSftpTransferPool({
|
||||
hostIds: connectedHosts.map((entry) => entry.host.id),
|
||||
activeHostId: activeHost?.protocol === "serial" ? undefined : activeHost?.id,
|
||||
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
|
||||
});
|
||||
|
||||
const { getConnectionCacheKey, leftPane } = sftp;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -29,7 +29,6 @@ import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from ".
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { useWarmSftpTransferPool } from "../application/state/sftp/useSftpTransferLifecycle";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
@@ -186,11 +185,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
useWarmSftpTransferPool({
|
||||
hostIds: connectedHosts.map((entry) => entry.host.id),
|
||||
warmTransferPoolForHost: sftp.warmTransferPoolForHost,
|
||||
});
|
||||
|
||||
// Register this useSftpState's writeTextFileByConnection with the bridge so
|
||||
// the editor tab's save path can reach the active SFTP session. The bridge
|
||||
// supports multiple simultaneous writers (SftpSidePanel inside terminals
|
||||
|
||||
@@ -38,16 +38,24 @@ test("resolved approval state retains only tool calls still present in messages"
|
||||
assert.deepEqual([...next], [["live-call", false]]);
|
||||
});
|
||||
|
||||
test("assistant content stays plain only when markdown is hidden", () => {
|
||||
test("assistant content stays plain when markdown is hidden or streaming", () => {
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
}), false);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: true,
|
||||
}), true);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
isStreaming: true,
|
||||
}), true);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
isStreaming: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders markdown for the streaming assistant message", () => {
|
||||
test("ChatMessageList renders plain text for the streaming assistant message", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
@@ -75,15 +83,16 @@ test("ChatMessageList renders markdown for the streaming assistant message", ()
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-ai-content="markdown"/);
|
||||
assert.match(markup, /data-ai-content="plain"/);
|
||||
assert.match(markup, /streaming-body/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="plain"/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="markdown"/);
|
||||
});
|
||||
|
||||
test("streaming markdown keeps the renderer in its animating mode", () => {
|
||||
test("streaming assistant content skips Streamdown while the turn is live", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /LazyMessageResponse isAnimating=\{!!isThisStreaming\}/);
|
||||
assert.match(source, /isStreaming: !!isThisStreaming/);
|
||||
assert.match(source, /shouldRenderAssistantAsPlainText\(\{[\s\S]*?isStreaming/);
|
||||
});
|
||||
|
||||
test("ChatMessageList hydrates markdown after streaming settles", () => {
|
||||
|
||||
@@ -113,8 +113,9 @@ export function shouldProvideVaultArtifactNavigation({
|
||||
|
||||
export function shouldRenderAssistantAsPlainText(options: {
|
||||
hideMarkdown: boolean;
|
||||
isStreaming?: boolean;
|
||||
}): boolean {
|
||||
return options.hideMarkdown;
|
||||
return options.hideMarkdown || !!options.isStreaming;
|
||||
}
|
||||
|
||||
const ASSISTANT_PLAIN_TEXT_CLASS = 'whitespace-pre-wrap break-words text-[13px] leading-[1.45]';
|
||||
@@ -702,6 +703,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
? <div className={ASSISTANT_PLAIN_TEXT_CLASS}>{message.content}</div>
|
||||
: shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown,
|
||||
isStreaming: !!isThisStreaming,
|
||||
})
|
||||
? (
|
||||
<div
|
||||
@@ -714,7 +716,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
: (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
|
||||
<div data-ai-content="markdown">
|
||||
<LazyMessageResponse isAnimating={!!isThisStreaming}>
|
||||
<LazyMessageResponse isAnimating={false}>
|
||||
{message.content}
|
||||
</LazyMessageResponse>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,19 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
const localStorageStub = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: localStorageStub,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
localStorage: localStorageStub,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = 'netcatty.aiDebug.hide';
|
||||
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = 'netcatty.aiDebug.profile';
|
||||
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
|
||||
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
|
||||
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
|
||||
export const AI_PANEL_FORCE_HIDE_SHELL = false;
|
||||
|
||||
@@ -17,9 +22,8 @@ export type AIPanelDiagnosticPart =
|
||||
| 'toolcalls';
|
||||
|
||||
function readLocalStorageValue(key: string): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
try {
|
||||
return window.localStorage.getItem(key) ?? '';
|
||||
return localStorageAdapter.readString(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -401,6 +401,75 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
return cleaned;
|
||||
}, [onUpdateNotes]);
|
||||
|
||||
const NOTE_DRAFT_DEBOUNCE_MS = 300;
|
||||
const draftNoteIdRef = useRef<string | null>(null);
|
||||
const draftTitleRef = useRef<string | null>(null);
|
||||
const draftContentRef = useRef<string | null>(null);
|
||||
const draftTimerRef = useRef<number | null>(null);
|
||||
const [draftNoteId, setDraftNoteId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState<string | null>(null);
|
||||
const [draftContent, setDraftContent] = useState<string | null>(null);
|
||||
|
||||
const clearDraftTimer = useCallback(() => {
|
||||
if (draftTimerRef.current !== null) {
|
||||
window.clearTimeout(draftTimerRef.current);
|
||||
draftTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flushNoteDraft = useCallback(() => {
|
||||
clearDraftTimer();
|
||||
const noteId = draftNoteIdRef.current;
|
||||
if (!noteId) return;
|
||||
const title = draftTitleRef.current;
|
||||
const content = draftContentRef.current;
|
||||
draftNoteIdRef.current = null;
|
||||
draftTitleRef.current = null;
|
||||
draftContentRef.current = null;
|
||||
setDraftNoteId(null);
|
||||
setDraftTitle(null);
|
||||
setDraftContent(null);
|
||||
if (title === null && content === null) return;
|
||||
commitNotes(sortedNotesRef.current.map((note) => {
|
||||
if (note.id !== noteId) return note;
|
||||
return {
|
||||
...note,
|
||||
...(title !== null ? { title } : {}),
|
||||
...(content !== null ? { content } : {}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}));
|
||||
}, [clearDraftTimer, commitNotes]);
|
||||
|
||||
const scheduleNoteDraftFlush = useCallback(() => {
|
||||
clearDraftTimer();
|
||||
draftTimerRef.current = window.setTimeout(() => {
|
||||
draftTimerRef.current = null;
|
||||
flushNoteDraft();
|
||||
}, NOTE_DRAFT_DEBOUNCE_MS);
|
||||
}, [clearDraftTimer, flushNoteDraft]);
|
||||
|
||||
const updateNoteDraft = useCallback((noteId: string, fields: { title?: string; content?: string }) => {
|
||||
if (draftNoteIdRef.current && draftNoteIdRef.current !== noteId) {
|
||||
flushNoteDraft();
|
||||
}
|
||||
draftNoteIdRef.current = noteId;
|
||||
setDraftNoteId(noteId);
|
||||
if (fields.title !== undefined) {
|
||||
draftTitleRef.current = fields.title;
|
||||
setDraftTitle(fields.title);
|
||||
}
|
||||
if (fields.content !== undefined) {
|
||||
draftContentRef.current = fields.content;
|
||||
setDraftContent(fields.content);
|
||||
}
|
||||
scheduleNoteDraftFlush();
|
||||
}, [flushNoteDraft, scheduleNoteDraftFlush]);
|
||||
|
||||
useEffect(() => () => {
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft]);
|
||||
|
||||
const noteTree = useMemo(() => {
|
||||
const tree = buildNoteTree(groups, sortedNotes);
|
||||
return {
|
||||
@@ -410,6 +479,26 @@ 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 = selectedNote && draftNoteId === selectedNote.id
|
||||
? {
|
||||
...selectedNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
...(draftContent !== null ? { content: draftContent } : {}),
|
||||
}
|
||||
: selectedNote;
|
||||
const overlayNoteView = overlayNote && draftNoteId === overlayNote.id
|
||||
? {
|
||||
...overlayNote,
|
||||
...(draftTitle !== null ? { title: draftTitle } : {}),
|
||||
...(draftContent !== null ? { content: draftContent } : {}),
|
||||
}
|
||||
: overlayNote;
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftNoteIdRef.current) return;
|
||||
if (draftNoteIdRef.current === selectedNoteId || draftNoteIdRef.current === overlayNoteId) return;
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft, overlayNoteId, selectedNoteId]);
|
||||
|
||||
useEffect(() => {
|
||||
const urls = activeDownloadUrlsRef.current;
|
||||
@@ -497,9 +586,18 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
const collapseAllGroups = () => setExpandedGroups(new Set());
|
||||
|
||||
const saveNote = (nextNote: VaultNote) => {
|
||||
flushNoteDraft();
|
||||
commitNotes(sortedNotes.map((note) => (note.id === nextNote.id ? nextNote : note)));
|
||||
};
|
||||
|
||||
const saveNoteTitleDraft = (note: VaultNote, title: string) => {
|
||||
updateNoteDraft(note.id, { title });
|
||||
};
|
||||
|
||||
const saveNoteContentDraft = (note: VaultNote, content: string) => {
|
||||
updateNoteDraft(note.id, { content });
|
||||
};
|
||||
|
||||
const handleOpenHostFromNote = useCallback((host: Host, noteId: string) => {
|
||||
onOpenHost?.(host, { noteId });
|
||||
}, [onOpenHost]);
|
||||
@@ -1515,41 +1613,41 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
|
||||
{!isSidebarMode && (
|
||||
<main className="flex min-w-0 flex-1 flex-col bg-background">
|
||||
{selectedNote ? (
|
||||
{selectedNoteView ? (
|
||||
<>
|
||||
<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">
|
||||
<input
|
||||
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
|
||||
value={selectedNote.title}
|
||||
value={selectedNoteView.title}
|
||||
placeholder={t("notes.title.placeholder")}
|
||||
onChange={(event) => saveNote({
|
||||
...selectedNote,
|
||||
title: event.target.value,
|
||||
updatedAt: Date.now(),
|
||||
})}
|
||||
onChange={(event) => saveNoteTitleDraft(selectedNoteView, event.target.value)}
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
{renderNoteExportButton(selectedNote)}
|
||||
{renderNoteExportButton(selectedNoteView)}
|
||||
{renderNoteModeToggle()}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="min-h-full w-full px-8 pt-2 pb-6">
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={selectedNote.id}>
|
||||
<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={selectedNoteView.id}>
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
key={selectedNote.id}
|
||||
value={selectedNote.content}
|
||||
key={selectedNoteView.id}
|
||||
value={selectedNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
onChange={(content) => saveNote({
|
||||
...selectedNote,
|
||||
content,
|
||||
updatedAt: Date.now(),
|
||||
})}
|
||||
onChange={(content) => saveNoteContentDraft(selectedNoteView, content)}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNote.id)}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -1582,7 +1680,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSidebarMode && overlayNote && (
|
||||
{isSidebarMode && overlayNoteView && (
|
||||
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
@@ -1602,7 +1700,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<TooltipContent side="bottom">{t("common.back")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="min-w-0 flex-1 truncate px-1 text-xs font-medium leading-4 text-foreground">
|
||||
{overlayNote.title || t("notes.title.placeholder")}
|
||||
{overlayNoteView.title || t("notes.title.placeholder")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
@@ -1610,35 +1708,35 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
|
||||
value={overlayNote.title}
|
||||
value={overlayNoteView.title}
|
||||
placeholder={t("notes.title.placeholder")}
|
||||
onChange={(event) => saveNote({
|
||||
...overlayNote,
|
||||
title: event.target.value,
|
||||
updatedAt: Date.now(),
|
||||
})}
|
||||
onChange={(event) => saveNoteTitleDraft(overlayNoteView, event.target.value)}
|
||||
onBlur={() => flushNoteDraft()}
|
||||
/>
|
||||
</div>
|
||||
{renderNoteExportButton(overlayNote)}
|
||||
{renderNoteExportButton(overlayNoteView)}
|
||||
{renderNoteModeToggle()}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="min-h-full w-full px-4 pt-2 pb-6">
|
||||
<LazyLoadBoundary name="Notes editor" resetKey={overlayNote.id}>
|
||||
<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={overlayNoteView.id}>
|
||||
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
||||
<InlineMarkdownEditor
|
||||
key={overlayNote.id}
|
||||
value={overlayNote.content}
|
||||
key={overlayNoteView.id}
|
||||
value={overlayNoteView.content}
|
||||
placeholder={t("notes.editor.placeholder")}
|
||||
editorMode={noteEditorMode}
|
||||
onChange={(content) => saveNote({
|
||||
...overlayNote,
|
||||
content,
|
||||
updatedAt: Date.now(),
|
||||
})}
|
||||
onChange={(content) => saveNoteContentDraft(overlayNoteView, content)}
|
||||
previewEmptyLabel={t("notes.preview.empty")}
|
||||
hosts={hosts}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNote.id)}
|
||||
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
|
||||
onOpenExternalLink={openExternal}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { KnownHost } from "../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../domain/knownHosts";
|
||||
import { toHostKeyInfo, type HostKeyVerificationRequest } from "../terminal/hostKeyVerification";
|
||||
import type { HostKeyInfo } from "../terminal/TerminalHostKeyVerification";
|
||||
|
||||
@@ -45,12 +46,4 @@ export const createKnownHostFromPortForwardHostKeyInfo = (
|
||||
hostKeyInfo: HostKeyInfo,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => ({
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
});
|
||||
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
|
||||
@@ -2,26 +2,19 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
normalizeExternalMcpIdleTimeoutMinutes,
|
||||
normalizeExternalMcpMode,
|
||||
normalizeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpIdleTimeoutMinutes,
|
||||
readExternalMcpMode,
|
||||
readExternalMcpSilentSessions,
|
||||
readSessionIdleTimeoutMinutes,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpIdleTimeoutMinutes,
|
||||
writeExternalMcpMode,
|
||||
writeExternalMcpSilentSessions,
|
||||
writeSessionIdleTimeoutMinutes,
|
||||
type ExternalMcpMode,
|
||||
useExternalMcpToggleState,
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from "../../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { emitAIStateChanged } from "../../../../application/state/aiStateEvents";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
@@ -328,26 +321,20 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((nextMode: ExternalMcpMode) => {
|
||||
const normalized = normalizeExternalMcpMode(nextMode);
|
||||
const normalized = writeExternalMcpMode(nextMode);
|
||||
setModeRaw(normalized);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
|
||||
pushConfig(normalized, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
|
||||
}, [idleTimeoutMinutes, pushConfig, sessionIdleTimeoutMinutes]);
|
||||
|
||||
const setIdleTimeoutMinutes = useCallback((minutes: number) => {
|
||||
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
|
||||
const normalized = writeExternalMcpIdleTimeoutMinutes(minutes);
|
||||
setIdleTimeoutRaw(normalized);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
|
||||
pushConfig(mode, normalized, sessionIdleTimeoutMinutes);
|
||||
}, [mode, pushConfig, sessionIdleTimeoutMinutes]);
|
||||
|
||||
const setSessionIdleTimeoutMinutes = useCallback((minutes: number) => {
|
||||
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
|
||||
const normalized = writeSessionIdleTimeoutMinutes(minutes);
|
||||
setSessionIdleTimeoutRaw(normalized);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
|
||||
pushConfig(mode, idleTimeoutMinutes, normalized);
|
||||
}, [idleTimeoutMinutes, mode, pushConfig]);
|
||||
|
||||
|
||||
+10
-19
@@ -24,6 +24,16 @@ import {
|
||||
import React from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { SftpFileEntry } from '../../types';
|
||||
import {
|
||||
formatDate as formatDateFromState,
|
||||
isNavigableDirectory,
|
||||
} from '../../application/state/sftp/utils';
|
||||
|
||||
export { isNavigableDirectory };
|
||||
|
||||
/** Accept undefined timestamps from file-list rows. */
|
||||
export const formatDate = (timestamp: number | undefined): string =>
|
||||
formatDateFromState(timestamp ?? 0);
|
||||
|
||||
// Pre-built icon maps for O(1) lookup in getFileIcon
|
||||
type IconDef = [LucideIcon, string?];
|
||||
@@ -199,17 +209,6 @@ export const formatTransferBytes = (bytes: number): string => {
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date as YYYY-MM-DD hh:mm in local timezone
|
||||
*/
|
||||
export const formatDate = (timestamp: number | undefined): string => {
|
||||
if (!timestamp) return '--';
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return '--';
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format speed with appropriate unit
|
||||
*/
|
||||
@@ -339,14 +338,6 @@ export const sortSftpEntries = (
|
||||
return sorted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an entry is navigable like a directory
|
||||
* This includes regular directories and symlinks that point to directories
|
||||
*/
|
||||
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a file is hidden
|
||||
* - Windows: checks the `hidden` attribute (set by localFsBridge)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { Highlighter, Plus, Trash2, RotateCcw } from 'lucide-react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host, KeywordHighlightRule } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -77,7 +76,7 @@ export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverPr
|
||||
}
|
||||
|
||||
const newRule: KeywordHighlightRule = {
|
||||
id: uuidv4(),
|
||||
id: crypto.randomUUID(),
|
||||
label: newRuleLabel.trim(),
|
||||
patterns: [newRulePattern.trim()],
|
||||
color: newRuleColor,
|
||||
|
||||
@@ -446,52 +446,3 @@ export function resolveAutocompleteAnchorInViewport(
|
||||
expandUpward: shouldExpandAutocompleteUpward(cursorY, spaceBelow, spaceAbove, estimatedPopupHeight),
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use resolveAutocompleteAnchorInViewport with the xterm container. */
|
||||
export function calculatePopupPosition(
|
||||
term: XTerm,
|
||||
itemCount: number,
|
||||
): {
|
||||
position: { x: number; y: number };
|
||||
cursorLineTop: number;
|
||||
cursorLineBottom: number;
|
||||
expandUpward: boolean;
|
||||
} {
|
||||
const termElement = term.element;
|
||||
if (!termElement) {
|
||||
return {
|
||||
position: { x: 0, y: 0 },
|
||||
cursorLineTop: 0,
|
||||
cursorLineBottom: 0,
|
||||
expandUpward: false,
|
||||
};
|
||||
}
|
||||
|
||||
const dims = getXTermCellDimensions(term);
|
||||
const buffer = term.buffer.active;
|
||||
const cursorX = buffer.cursorX;
|
||||
const cursorY = buffer.cursorY;
|
||||
const cursorLineTop = cursorY * dims.height;
|
||||
const cursorLineBottom = (cursorY + 1) * dims.height;
|
||||
const estimatedPopupHeight = estimatePopupHeight(itemCount);
|
||||
const totalRows = term.rows;
|
||||
const spaceBelow = (totalRows - cursorY - 1) * dims.height;
|
||||
const spaceAbove = cursorY * dims.height;
|
||||
const expandUpward = shouldExpandAutocompleteUpward(cursorY, spaceBelow, spaceAbove, estimatedPopupHeight);
|
||||
|
||||
if (expandUpward) {
|
||||
return {
|
||||
position: { x: cursorX * dims.width, y: cursorY * dims.height },
|
||||
cursorLineTop,
|
||||
cursorLineBottom,
|
||||
expandUpward: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
position: { x: cursorX * dims.width, y: (cursorY + 1) * dims.height + 4 },
|
||||
cursorLineTop,
|
||||
cursorLineBottom,
|
||||
expandUpward: false,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Host, KnownHost } from "../../types";
|
||||
import type { HostKeyInfo } from "./TerminalHostKeyVerification";
|
||||
import { createKnownHostFromHostKeyInfo as createKnownHostFromHostKeyInfoDomain } from "../../domain/knownHosts";
|
||||
|
||||
export type HostKeyVerificationRequest = {
|
||||
hostname: string;
|
||||
@@ -28,12 +29,8 @@ export const createKnownHostFromHostKeyInfo = (
|
||||
host: Pick<Host, "port">,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => ({
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || host.port || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
): KnownHost => createKnownHostFromHostKeyInfoDomain(hostKeyInfo, {
|
||||
defaultPort: host.port,
|
||||
now,
|
||||
idSuffix,
|
||||
});
|
||||
@@ -971,9 +971,6 @@ const resolveLastPaintBufferLine = (
|
||||
export const getTerminalLineTimestampLedgerCount = (term: XTerm): number =>
|
||||
getTimestampStore(term).ledger.length;
|
||||
|
||||
/** @deprecated No-op; paint reads the ledger directly. */
|
||||
export const materializeTimestampLedgerToMarkers = (_term: XTerm): number => 0;
|
||||
|
||||
const internTerminalLineTimestampLabel = (
|
||||
store: TimestampStore,
|
||||
date: Date,
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
export interface CodeTextareaProps
|
||||
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "spellCheck"> {
|
||||
showLineNumbers?: boolean;
|
||||
/** Applied to the outer bordered container when line numbers are shown. */
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
function countLines(value: string): number {
|
||||
if (!value) return 1;
|
||||
return value.split("\n").length;
|
||||
}
|
||||
|
||||
const CodeTextarea = React.forwardRef<HTMLTextAreaElement, CodeTextareaProps>(
|
||||
({ className, wrapperClassName, value, showLineNumbers = true, onScroll, ...props }, ref) => {
|
||||
const gutterRef = React.useRef<HTMLDivElement>(null);
|
||||
const text = typeof value === "string" ? value : String(value ?? "");
|
||||
const lineCount = countLines(text);
|
||||
const lineNumbers = React.useMemo(
|
||||
() => Array.from({ length: lineCount }, (_, i) => i + 1),
|
||||
[lineCount],
|
||||
);
|
||||
const gutterWidthCh = Math.max(2, String(lineCount).length) + 1;
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (gutterRef.current) {
|
||||
gutterRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
}
|
||||
onScroll?.(e);
|
||||
};
|
||||
|
||||
const editorClass = cn(
|
||||
"w-full flex-1 resize-none border-0 bg-transparent px-2 py-2 font-mono text-xs leading-5",
|
||||
"placeholder:text-muted-foreground focus-visible:outline-none",
|
||||
"whitespace-pre overflow-auto",
|
||||
className,
|
||||
);
|
||||
|
||||
if (!showLineNumbers) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs leading-5",
|
||||
"placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"whitespace-pre",
|
||||
className,
|
||||
)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
data-gramm="false"
|
||||
onScroll={onScroll}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full overflow-hidden rounded-md border border-input bg-background transition-colors",
|
||||
"focus-within:border-ring",
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={gutterRef}
|
||||
aria-hidden
|
||||
className="shrink-0 overflow-hidden border-r border-border/60 bg-muted/30 py-2 pl-2 pr-1.5 select-none"
|
||||
style={{ width: `${gutterWidthCh}ch` }}
|
||||
>
|
||||
<pre className="font-mono text-[11px] leading-5 text-muted-foreground text-right m-0">
|
||||
{lineNumbers.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
className={editorClass}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
data-gramm="false"
|
||||
onScroll={handleScroll}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
CodeTextarea.displayName = "CodeTextarea";
|
||||
|
||||
export { CodeTextarea };
|
||||
@@ -429,10 +429,4 @@ export const healPoisonedSecretsForMerge = (
|
||||
};
|
||||
};
|
||||
|
||||
/** @deprecated Prefer healPoisonedSecretsForMerge(remote, local, base). */
|
||||
export const healPoisonedRemoteSecretsForMerge = (
|
||||
remote: SyncPayload,
|
||||
local: SyncPayload,
|
||||
base: SyncPayload | null | undefined,
|
||||
): SyncPayload => healPoisonedSecretsForMerge(remote, local, base);
|
||||
|
||||
@@ -190,3 +190,38 @@ export const normalizeKnownHosts = (knownHosts: KnownHost[]): KnownHost[] => {
|
||||
});
|
||||
return changed ? next : knownHosts;
|
||||
};
|
||||
|
||||
/** Minimal host-key shape shared by terminal / SFTP / port-forward verification. */
|
||||
export type HostKeyInfoLike = {
|
||||
hostname: string;
|
||||
port?: number;
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey?: string;
|
||||
knownHostId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a KnownHost record from verified host-key info.
|
||||
* Call sites may supply a fallback port (e.g. the SSH host's configured port).
|
||||
*/
|
||||
export const createKnownHostFromHostKeyInfo = (
|
||||
hostKeyInfo: HostKeyInfoLike,
|
||||
options?: {
|
||||
defaultPort?: number;
|
||||
now?: number;
|
||||
idSuffix?: string;
|
||||
},
|
||||
): KnownHost => {
|
||||
const now = options?.now ?? Date.now();
|
||||
const idSuffix = options?.idSuffix ?? Math.random().toString(36).slice(2, 11);
|
||||
return {
|
||||
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
|
||||
hostname: hostKeyInfo.hostname,
|
||||
port: hostKeyInfo.port || options?.defaultPort || 22,
|
||||
keyType: hostKeyInfo.keyType,
|
||||
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
|
||||
fingerprint: hostKeyInfo.fingerprint,
|
||||
discoveredAt: now,
|
||||
};
|
||||
};
|
||||
+17
-34
@@ -11,6 +11,13 @@ import type {
|
||||
ConvergentSyncEnvelopeV2,
|
||||
ConvergentSyncStateV2,
|
||||
} from './convergentSync';
|
||||
import {
|
||||
BUILTIN_CLOUD_PROVIDERS,
|
||||
isBuiltinCloudProvider,
|
||||
providerConnectionStorageKey,
|
||||
type BuiltinCloudProvider,
|
||||
type CloudProviderId,
|
||||
} from './cloudProviderIds';
|
||||
|
||||
export type {
|
||||
ConvergentFieldConflict,
|
||||
@@ -18,6 +25,16 @@ export type {
|
||||
ConvergentSyncStateV2,
|
||||
} from './convergentSync';
|
||||
|
||||
export {
|
||||
BUILTIN_CLOUD_PROVIDERS,
|
||||
isBuiltinCloudProvider,
|
||||
providerConnectionStorageKey,
|
||||
type BuiltinCloudProvider,
|
||||
};
|
||||
|
||||
/** Built-in short IDs or namespaced plugin contribution IDs. */
|
||||
export type CloudProvider = CloudProviderId;
|
||||
|
||||
// ============================================================================
|
||||
// Security State Machine
|
||||
// ============================================================================
|
||||
@@ -54,25 +71,6 @@ export type ConflictResolution =
|
||||
// Cloud Provider Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Supported cloud storage providers.
|
||||
* Built-in short IDs remain stable; plugin sync Providers use namespaced
|
||||
* contribution IDs (e.g. com.example.backup.sync).
|
||||
*/
|
||||
export type BuiltinCloudProvider = 'github' | 'google' | 'onedrive' | 'webdav' | 's3';
|
||||
export type CloudProvider = BuiltinCloudProvider | (string & {});
|
||||
|
||||
export const BUILTIN_CLOUD_PROVIDERS: readonly BuiltinCloudProvider[] = [
|
||||
'github',
|
||||
'google',
|
||||
'onedrive',
|
||||
'webdav',
|
||||
's3',
|
||||
] as const;
|
||||
|
||||
export const isBuiltinCloudProvider = (provider: string): provider is BuiltinCloudProvider =>
|
||||
(BUILTIN_CLOUD_PROVIDERS as readonly string[]).includes(provider);
|
||||
|
||||
export type WebDAVAuthType = 'basic' | 'digest' | 'token';
|
||||
|
||||
export interface WebDAVConfig {
|
||||
@@ -712,21 +710,6 @@ export const SYNC_STORAGE_KEYS = {
|
||||
CONVERGENT_PROVIDER_BASELINE: 'netcatty_convergent_sync_provider_baseline_v2',
|
||||
} as const;
|
||||
|
||||
/** Resolve the localStorage key used for a provider connection (built-in or plugin). */
|
||||
export function providerConnectionStorageKey(provider: CloudProvider): string {
|
||||
if (isBuiltinCloudProvider(provider)) {
|
||||
const map: Record<BuiltinCloudProvider, string> = {
|
||||
github: SYNC_STORAGE_KEYS.PROVIDER_GITHUB,
|
||||
google: SYNC_STORAGE_KEYS.PROVIDER_GOOGLE,
|
||||
onedrive: SYNC_STORAGE_KEYS.PROVIDER_ONEDRIVE,
|
||||
webdav: SYNC_STORAGE_KEYS.PROVIDER_WEBDAV,
|
||||
s3: SYNC_STORAGE_KEYS.PROVIDER_S3,
|
||||
};
|
||||
return map[provider];
|
||||
}
|
||||
return `netcatty_provider_plugin_v1:${provider}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
@@ -94,6 +94,23 @@ module.exports = {
|
||||
'!node_modules/monaco-editor/**/*',
|
||||
'!node_modules/react/**/*',
|
||||
'!node_modules/react-dom/**/*',
|
||||
'!node_modules/ai/**/*',
|
||||
'!node_modules/@ai-sdk/**/*',
|
||||
'!node_modules/@mdxeditor/**/*',
|
||||
'!node_modules/streamdown/**/*',
|
||||
'!node_modules/@streamdown/**/*',
|
||||
'!node_modules/@tanstack/react-virtual/**/*',
|
||||
'!node_modules/pinyin-pro/**/*',
|
||||
'!node_modules/re2js/**/*',
|
||||
'!node_modules/@eslint-community/regexpp/**/*',
|
||||
'!node_modules/clsx/**/*',
|
||||
'!node_modules/tailwind-merge/**/*',
|
||||
'!node_modules/use-stick-to-bottom/**/*',
|
||||
'!node_modules/lexical/**/*',
|
||||
'!node_modules/@lexical/**/*',
|
||||
'!node_modules/@codemirror/**/*',
|
||||
'!node_modules/shiki/**/*',
|
||||
'!node_modules/@shiki/**/*',
|
||||
// Heavy cloud completion specs are intentionally not bundled. The main
|
||||
// process filters the same prefixes so dev and packaged builds behave
|
||||
// consistently.
|
||||
|
||||
@@ -9,12 +9,13 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { BaseAgent } = require("ssh2/lib/agent.js");
|
||||
const { parseKey } = require("ssh2/lib/protocol/keyParser.js");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
|
||||
const DEBUG_SSH = process.env.NETCATTY_SSH_DEBUG === "1";
|
||||
|
||||
// Debug logger (disabled by default)
|
||||
const logFile = DEBUG_SSH
|
||||
? path.join(require("os").tmpdir(), "netcatty-agent.log")
|
||||
? tempDirBridge.getTempFilePath("netcatty-agent.log")
|
||||
: null;
|
||||
const log = (msg, data) => {
|
||||
if (!DEBUG_SSH) return;
|
||||
|
||||
@@ -926,7 +926,7 @@ main();
|
||||
*/
|
||||
function cleanupStaleEtTempDirs() {
|
||||
try {
|
||||
const tempDir = tempDirBridge.getTempDir?.() || path.join(os.tmpdir(), "Netcatty");
|
||||
const tempDir = tempDirBridge.getTempDir();
|
||||
if (!fs.existsSync(tempDir)) return;
|
||||
const entries = fs.readdirSync(tempDir);
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -396,6 +396,14 @@ test('accepted steering keeps buffered results for existing tool calls on their
|
||||
assert.equal(session.messages[3].content, '');
|
||||
|
||||
onEvent?.({ type: 'text-delta', textDelta: 'continued after steer' });
|
||||
// Text deltas are rAF-batched like Catty; wait one frame before asserting.
|
||||
await new Promise<void>((resolve) => {
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(() => resolve());
|
||||
return;
|
||||
}
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
assert.equal(session.messages[3].content, 'continued after steer');
|
||||
|
||||
onDone?.();
|
||||
|
||||
@@ -162,6 +162,59 @@ async function runExternalTurn(
|
||||
maybeCreateAssistantMsg();
|
||||
ui.updateMessageById(sessionId, activeAssistantMessageId, updater);
|
||||
};
|
||||
let pendingText = '';
|
||||
let rafId: number | null = null;
|
||||
const appendTextToActiveAssistant = (textChunk: string) => {
|
||||
updateActiveAssistant(msg => ({
|
||||
...msg,
|
||||
content: msg.content + textChunk,
|
||||
statusText: undefined,
|
||||
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
|
||||
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
|
||||
}));
|
||||
};
|
||||
const flushPendingText = () => {
|
||||
if (pendingText) {
|
||||
const textChunk = pendingText;
|
||||
pendingText = '';
|
||||
runOrBufferUiOperation(() => appendTextToActiveAssistant(textChunk));
|
||||
}
|
||||
rafId = null;
|
||||
};
|
||||
const scheduleFrame = (cb: () => void): number => (
|
||||
typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame(cb)
|
||||
: setTimeout(cb, 0) as unknown as number
|
||||
);
|
||||
const cancelFrame = (id: number): void => {
|
||||
if (typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(id);
|
||||
return;
|
||||
}
|
||||
clearTimeout(id);
|
||||
};
|
||||
const cancelPendingTextFlush = () => {
|
||||
if (rafId !== null) {
|
||||
cancelFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
const enqueueTextDelta = (textChunk: string) => {
|
||||
if (!textChunk) return;
|
||||
// While steering buffers UI ops, skip rAF so text enters the buffer immediately.
|
||||
if (steerInFlight) {
|
||||
runOrBufferUiOperation(() => appendTextToActiveAssistant(textChunk));
|
||||
return;
|
||||
}
|
||||
pendingText += textChunk;
|
||||
if (rafId === null) {
|
||||
rafId = scheduleFrame(flushPendingText);
|
||||
}
|
||||
};
|
||||
const flushTextBeforeNonTextEvent = () => {
|
||||
cancelPendingTextFlush();
|
||||
flushPendingText();
|
||||
};
|
||||
|
||||
const toolNamesByCallId = new Map<string, string>();
|
||||
const toolCallMessageIds = new Map<string, string>();
|
||||
@@ -190,28 +243,29 @@ async function runExternalTurn(
|
||||
updateActiveAssistant(msg => ({ ...msg, usage }));
|
||||
};
|
||||
const callbacks: SdkAgentCallbacks = {
|
||||
onTextDelta: (text: string) => runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({
|
||||
...msg,
|
||||
content: msg.content + text,
|
||||
statusText: undefined,
|
||||
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
|
||||
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
|
||||
}));
|
||||
}),
|
||||
onThinkingDelta: (text: string) => runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({
|
||||
...msg,
|
||||
thinking: (msg.thinking || '') + text,
|
||||
}));
|
||||
}),
|
||||
onThinkingDone: () => runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({
|
||||
...msg,
|
||||
thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
|
||||
}));
|
||||
}),
|
||||
onTextDelta: (text: string) => {
|
||||
enqueueTextDelta(text);
|
||||
},
|
||||
onThinkingDelta: (text: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({
|
||||
...msg,
|
||||
thinking: (msg.thinking || '') + text,
|
||||
}));
|
||||
});
|
||||
},
|
||||
onThinkingDone: () => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({
|
||||
...msg,
|
||||
thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
|
||||
}));
|
||||
});
|
||||
},
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
const id = toolCallId || `tc_${Date.now()}`;
|
||||
maybeCreateAssistantMsg();
|
||||
@@ -226,6 +280,7 @@ async function runExternalTurn(
|
||||
});
|
||||
},
|
||||
onToolResult: (toolCallId: string, result: string, toolName?: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
const existingToolCallMessageId = toolCallMessageIds.get(toolCallId);
|
||||
runOrBufferUiOperation(() => {
|
||||
const effectiveToolName = toolName ?? toolNamesByCallId.get(toolCallId);
|
||||
@@ -264,27 +319,47 @@ async function runExternalTurn(
|
||||
flushBeforeSteerBoundary: existingToolCallMessageId !== undefined,
|
||||
});
|
||||
},
|
||||
onFileChange: (activity) => runOrBufferUiOperation(() => updateActivity(activity)),
|
||||
onWebSearch: (activity) => runOrBufferUiOperation(() => updateActivity(activity)),
|
||||
onPlanUpdate: (activity) => runOrBufferUiOperation(() => updateActivity(activity)),
|
||||
onWarning: (activity) => runOrBufferUiOperation(() => updateActivity(activity)),
|
||||
onUsage: (usage: AgentUsage) => runOrBufferUiOperation(() => {
|
||||
actualUsageReported = true;
|
||||
updateUsage(usage);
|
||||
}),
|
||||
onStatus: (message: string) => runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
|
||||
}),
|
||||
onFileChange: (activity) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateActivity(activity));
|
||||
},
|
||||
onWebSearch: (activity) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateActivity(activity));
|
||||
},
|
||||
onPlanUpdate: (activity) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateActivity(activity));
|
||||
},
|
||||
onWarning: (activity) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateActivity(activity));
|
||||
},
|
||||
onUsage: (usage: AgentUsage) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
actualUsageReported = true;
|
||||
updateUsage(usage);
|
||||
});
|
||||
},
|
||||
onStatus: (message: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
|
||||
});
|
||||
},
|
||||
onHook: (hookEvent: string, payload: Record<string, unknown>) => {
|
||||
// Surface lifecycle hooks as status text so the user sees tool activity.
|
||||
const toolName = (payload.toolName as string) || '';
|
||||
if (hookEvent === 'PreToolUse' && toolName) {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({ ...msg, statusText: `Running ${toolName}…` }));
|
||||
});
|
||||
} else if (hookEvent === 'Notification') {
|
||||
const message = (payload.message as string) || '';
|
||||
if (message) {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => {
|
||||
updateActiveAssistant(msg => ({ ...msg, statusText: message }));
|
||||
});
|
||||
@@ -305,10 +380,13 @@ async function runExternalTurn(
|
||||
context.updateExternalSessionId?.(sessionId, externalSessionId);
|
||||
},
|
||||
onError: (error: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
ui.reportStreamError(sessionId, signal, error);
|
||||
ui.setStreamingForScope(sessionId, false);
|
||||
},
|
||||
onDone: () => {},
|
||||
onDone: () => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
},
|
||||
};
|
||||
|
||||
const liveTurn: LiveExternalTurn = {
|
||||
@@ -320,6 +398,8 @@ async function runExternalTurn(
|
||||
async steer(steerInput) {
|
||||
if (steerInFlight) return { status: 'busy' };
|
||||
if (ended || signal.aborted) return { status: 'cancelled' };
|
||||
// Commit any rAF-batched text before steering buffers UI ops.
|
||||
flushTextBeforeNonTextEvent();
|
||||
steerInFlight = true;
|
||||
const result = await steerSdkAgentTurn(
|
||||
netcattyBridge,
|
||||
@@ -388,6 +468,7 @@ async function runExternalTurn(
|
||||
|
||||
const estimatedUsage = resolveEstimatedUsageFallback(trimmed, actualUsageReported);
|
||||
if (estimatedUsage) {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateUsage(estimatedUsage));
|
||||
ctx.emit({
|
||||
id: `usage-${ctx.turnId}`,
|
||||
@@ -401,6 +482,7 @@ async function runExternalTurn(
|
||||
} finally {
|
||||
ended = true;
|
||||
liveTurn.ended = true;
|
||||
flushTextBeforeNonTextEvent();
|
||||
clearCodebuddyElicitationsForChat(sessionId);
|
||||
if (steerInFlight) {
|
||||
steerInFlight = false;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* @deprecated Transfer channel pool no longer parks SSH connections.
|
||||
* Keep-alive is owned by the main-process SSH transport registry
|
||||
* (`sshTransportIdleTtl.ts` / settings "SSH connection keep-alive").
|
||||
*
|
||||
* These symbols remain for import stability in older tests/call sites.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_SSH_TRANSPORT_IDLE_TTL_MS,
|
||||
SSH_TRANSPORT_IDLE_TTL_PRESETS_MS,
|
||||
resolveSshTransportIdleTtlMs,
|
||||
} from "./sshTransportIdleTtl.ts";
|
||||
|
||||
/** @deprecated Use DEFAULT_SSH_TRANSPORT_IDLE_TTL_MS */
|
||||
export const DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS = DEFAULT_SSH_TRANSPORT_IDLE_TTL_MS;
|
||||
|
||||
/** @deprecated Use SSH_TRANSPORT_IDLE_TTL_PRESETS_MS */
|
||||
export const SFTP_TRANSFER_POOL_IDLE_TTL_PRESETS_MS = SSH_TRANSPORT_IDLE_TTL_PRESETS_MS;
|
||||
|
||||
/** @deprecated */
|
||||
export type SftpTransferPoolIdleTtlMs = (typeof SFTP_TRANSFER_POOL_IDLE_TTL_PRESETS_MS)[number];
|
||||
|
||||
/** @deprecated Use resolveSshTransportIdleTtlMs */
|
||||
export function resolveSftpTransferPoolIdleTtlMs(
|
||||
readStoredValue: () => number | null | undefined,
|
||||
): number {
|
||||
return resolveSshTransportIdleTtlMs(readStoredValue);
|
||||
}
|
||||
|
||||
/** @deprecated Transfer channels close when idle; always treated as reclaim-enabled. */
|
||||
export function isTransferPoolIdleReclaimDisabled(_idleTtlMs: number): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -201,6 +201,10 @@ export const STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN = 'netcatty_ai_exter
|
||||
export const STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES = 'netcatty_ai_session_idle_timeout_minutes_v1';
|
||||
/** External MCP: whether host_open sessions stay hidden from the tab bar (default false). */
|
||||
export const STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS = 'netcatty_ai_external_mcp_silent_sessions_v1';
|
||||
/** AI panel diagnostic hide list (comma-separated part names). */
|
||||
export const STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE = 'netcatty.aiDebug.hide';
|
||||
/** AI panel React profiler toggle. */
|
||||
export const STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE = 'netcatty.aiDebug.profile';
|
||||
|
||||
// SFTP Transfer Concurrency
|
||||
export const STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY = 'netcatty_sftp_transfer_concurrency_v1';
|
||||
|
||||
Generated
+17
-19
@@ -17,9 +17,7 @@
|
||||
"@ai-sdk/anthropic": "^4.0.0",
|
||||
"@ai-sdk/google": "^4.0.0",
|
||||
"@ai-sdk/openai": "^4.0.0",
|
||||
"@anthropic-ai/sdk": "0.111.0",
|
||||
"@aws-sdk/client-s3": "^3.956.0",
|
||||
"@connectrpc/connect": "^1.7.0",
|
||||
"@eslint-community/regexpp": "4.12.2",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/mona-sans": "^5.2.8",
|
||||
@@ -74,7 +72,6 @@
|
||||
"streamdown": "^2.4.0",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"use-stick-to-bottom": "^1.1.3",
|
||||
"uuid": "^13.0.0",
|
||||
"webdav": "^5.8.0",
|
||||
"zmodem.js": "^0.1.10",
|
||||
"zod": "^4.3.6"
|
||||
@@ -366,6 +363,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.111.0.tgz",
|
||||
"integrity": "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
@@ -1596,6 +1595,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@buttercup/fetch": {
|
||||
@@ -2026,6 +2026,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-1.7.0.tgz",
|
||||
"integrity": "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
@@ -7629,7 +7630,9 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
@@ -11605,7 +11608,9 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
"license": "Unlicense",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
@@ -13117,6 +13122,8 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
@@ -17821,6 +17828,8 @@
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
@@ -18321,7 +18330,9 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
@@ -18786,19 +18797,6 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/uvu": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz",
|
||||
|
||||
@@ -70,9 +70,7 @@
|
||||
"@ai-sdk/anthropic": "^4.0.0",
|
||||
"@ai-sdk/google": "^4.0.0",
|
||||
"@ai-sdk/openai": "^4.0.0",
|
||||
"@anthropic-ai/sdk": "0.111.0",
|
||||
"@aws-sdk/client-s3": "^3.956.0",
|
||||
"@connectrpc/connect": "^1.7.0",
|
||||
"@eslint-community/regexpp": "4.12.2",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/mona-sans": "^5.2.8",
|
||||
@@ -127,7 +125,6 @@
|
||||
"streamdown": "^2.4.0",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"use-stick-to-bottom": "^1.1.3",
|
||||
"uuid": "^13.0.0",
|
||||
"webdav": "^5.8.0",
|
||||
"zmodem.js": "^0.1.10",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -128,6 +128,37 @@ test("build.files trims release-only dependency payloads", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("build.files excludes Vite-bundled renderer-only packages", () => {
|
||||
const files = config.files;
|
||||
for (const glob of [
|
||||
"!node_modules/react/**/*",
|
||||
"!node_modules/react-dom/**/*",
|
||||
"!node_modules/@radix-ui/**/*",
|
||||
"!node_modules/ai/**/*",
|
||||
"!node_modules/@ai-sdk/**/*",
|
||||
"!node_modules/@mdxeditor/**/*",
|
||||
"!node_modules/streamdown/**/*",
|
||||
"!node_modules/@streamdown/**/*",
|
||||
"!node_modules/@tanstack/react-virtual/**/*",
|
||||
"!node_modules/pinyin-pro/**/*",
|
||||
"!node_modules/re2js/**/*",
|
||||
"!node_modules/@eslint-community/regexpp/**/*",
|
||||
"!node_modules/clsx/**/*",
|
||||
"!node_modules/tailwind-merge/**/*",
|
||||
"!node_modules/use-stick-to-bottom/**/*",
|
||||
"!node_modules/lexical/**/*",
|
||||
"!node_modules/@lexical/**/*",
|
||||
"!node_modules/@codemirror/**/*",
|
||||
"!node_modules/shiki/**/*",
|
||||
"!node_modules/@shiki/**/*",
|
||||
]) {
|
||||
assert.ok(
|
||||
files.includes(glob),
|
||||
`build.files must exclude Vite-bundled renderer package: ${glob}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("linux packaging uses multi-size build/icons instead of a single 1024px override", async () => {
|
||||
assert.equal(
|
||||
config.linux.icon,
|
||||
|
||||
Reference in new issue
Block a user