Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f8aa08994 | ||
|
|
fb522c5016 | ||
|
|
7272f2564d | ||
|
|
07a2f3a899 | ||
|
|
399e6a6f2d | ||
|
|
46d1cf1696 | ||
|
|
5be9bb58df | ||
|
|
cab4fc36ab | ||
|
|
53d3e05bb4 | ||
|
|
0c4de74c84 | ||
|
|
2a4feea40f | ||
|
|
faa90e1aa5 | ||
|
|
1aa96c3490 | ||
|
|
0e80955e96 | ||
|
|
7771592cf2 | ||
|
|
6e9e8fc40d | ||
|
|
67448cea65 | ||
|
|
770b06a9ee | ||
|
|
1d50b2c4a1 | ||
|
|
453202df8f | ||
|
|
a78c052d86 | ||
|
|
e6b0a551e8 | ||
|
|
38775245d2 | ||
|
|
fcb699ffb9 | ||
|
|
e889d8fc20 | ||
|
|
bf1c95500a | ||
|
|
f9d00c9d23 | ||
|
|
8fd7ff6475 | ||
|
|
02c80ae7d2 | ||
|
|
e5d3d02b17 | ||
|
|
78186d8d46 | ||
|
|
c899653621 | ||
|
|
a91fbcdd68 | ||
|
|
74b315e285 | ||
|
|
60eeafe7a9 | ||
|
|
ee2c21e712 | ||
|
|
e678ad3546 | ||
|
|
c47c780b48 | ||
|
|
88074ac9b3 | ||
|
|
59cb0c4b65 | ||
|
|
bf0bd193eb | ||
|
|
7661375925 | ||
|
|
308fb45985 | ||
|
|
f4aa6ddb46 | ||
|
|
f6cb73fdd6 | ||
|
|
3c100b0ae2 | ||
|
|
168e42b5fa | ||
|
|
2ce6bd5ed1 | ||
|
|
7bd5d6465a | ||
|
|
65387d4c61 | ||
|
|
6084e8e94f | ||
|
|
3ccc5c9fc6 | ||
|
|
d07859f604 | ||
|
|
88a322a03b | ||
|
|
0e02bbc2fb | ||
|
|
affd9217e2 | ||
|
|
7b4a349e3f | ||
|
|
7dc5ab5035 | ||
|
|
3e8965f9a9 | ||
|
|
23a27bf544 | ||
|
|
86a815ad46 | ||
|
|
cb4fb091aa | ||
|
|
b30696c98b | ||
|
|
6b8f05c65a | ||
|
|
64dd3a4a2f | ||
|
|
88732040aa | ||
|
|
b9f3bfa8bb | ||
|
|
b7ec3c12f7 | ||
|
|
d20a18b862 | ||
|
|
ff6b4a4625 | ||
|
|
5a94b4cf39 | ||
|
|
3963cd4af9 | ||
|
|
5b2a048917 | ||
|
|
2414cb00e4 | ||
|
|
03f980e939 | ||
|
|
ac819fd4fd | ||
|
|
fb9400a5fb | ||
|
|
7da983a56c | ||
|
|
344b226ce8 | ||
|
|
86e47b5f9e | ||
|
|
37012da26a | ||
|
|
0fd6a8c31d | ||
|
|
10af904681 | ||
|
|
b02b83f225 | ||
|
|
bca5d63a4e | ||
|
|
67c5571df5 | ||
|
|
ea5320d94a | ||
|
|
ffd3111b71 | ||
|
|
b0949f1a1e | ||
|
|
84416d04bf |
89
.github/scripts/bump-homebrew-cask.sh
vendored
Executable file
89
.github/scripts/bump-homebrew-cask.sh
vendored
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# bump-homebrew-cask.sh — push a new version of the Netcatty cask to the
|
||||
# binaricat/homebrew-netcatty tap.
|
||||
#
|
||||
# Called from the release pipeline (`build.yml` → `homebrew-tap` job) after
|
||||
# the GitHub Release has been published with the signed + notarized DMGs.
|
||||
# Computes SHA-256 of the arm64 and x64 DMGs, rewrites the cask file, and
|
||||
# pushes the bump back to the tap repository using HOMEBREW_TAP_TOKEN.
|
||||
#
|
||||
# Required env vars:
|
||||
# VERSION — semver without leading "v" (e.g. 1.1.6)
|
||||
# HOMEBREW_TAP_TOKEN — PAT with contents:write on the tap repo
|
||||
#
|
||||
# Optional env vars:
|
||||
# TAP_REPO — default: binaricat/homebrew-netcatty
|
||||
# ARTIFACTS_DIR — default: artifacts
|
||||
# CASK_PATH — default: Casks/netcatty.rb
|
||||
set -euo pipefail
|
||||
|
||||
: "${VERSION:?VERSION env var required (no leading v)}"
|
||||
: "${HOMEBREW_TAP_TOKEN:?HOMEBREW_TAP_TOKEN env var required}"
|
||||
|
||||
TAP_REPO="${TAP_REPO:-binaricat/homebrew-netcatty}"
|
||||
ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
|
||||
CASK_PATH="${CASK_PATH:-Casks/netcatty.rb}"
|
||||
|
||||
ARM_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-arm64.dmg"
|
||||
X64_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-x64.dmg"
|
||||
|
||||
for f in "$ARM_DMG" "$X64_DMG"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "::error::Required DMG artifact not found: $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
ARM_SHA=$(shasum -a 256 "$ARM_DMG" | awk '{print $1}')
|
||||
X64_SHA=$(shasum -a 256 "$X64_DMG" | awk '{print $1}')
|
||||
|
||||
echo "Computed checksums:"
|
||||
echo " arm64: ${ARM_SHA}"
|
||||
echo " x64 : ${X64_SHA}"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${TAP_REPO}.git" \
|
||||
"$TMP/tap"
|
||||
cd "$TMP/tap"
|
||||
|
||||
if [[ ! -f "$CASK_PATH" ]]; then
|
||||
echo "::error::Cask file not found in tap: $CASK_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Patch the cask in place. The three lines we touch are anchored well enough
|
||||
# that we don't need anything fancier than sed:
|
||||
# - the `version "X.Y.Z"` line (single line, anchored to start)
|
||||
# - the `sha256 arm: "..."` line
|
||||
# - the ` intel: "..."` line (anchor on "intel:" at start, after the
|
||||
# leading whitespace, so we don't accidentally match the `arch arm:
|
||||
# "...", intel: "..."` line earlier in the file)
|
||||
sed -i -E 's|^(\s*version)\s+"[^"]+"|\1 "'"$VERSION"'"|' "$CASK_PATH"
|
||||
sed -i -E 's|(sha256\s+arm:\s+)"[^"]+"|\1"'"$ARM_SHA"'"|' "$CASK_PATH"
|
||||
sed -i -E 's|^(\s*intel:\s+)"[^"]+"|\1"'"$X64_SHA"'"|' "$CASK_PATH"
|
||||
|
||||
# Sanity-check: parsed file should still be valid Ruby. Catches a broken
|
||||
# substitution before we push.
|
||||
if command -v ruby >/dev/null 2>&1; then
|
||||
ruby -c "$CASK_PATH" >/dev/null
|
||||
fi
|
||||
|
||||
if git diff --quiet; then
|
||||
echo "Cask already at ${VERSION} with matching checksums — nothing to push."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Cask diff:"
|
||||
git --no-pager diff "$CASK_PATH"
|
||||
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git add "$CASK_PATH"
|
||||
git commit -m "Bump netcatty to ${VERSION}"
|
||||
git push origin HEAD:main
|
||||
|
||||
echo "Pushed bump for ${VERSION} to ${TAP_REPO}."
|
||||
30
.github/workflows/build.yml
vendored
30
.github/workflows/build.yml
vendored
@@ -604,3 +604,33 @@ jobs:
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
homebrew-tap:
|
||||
name: bump homebrew tap
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
# Only stable release tags update the Cask. Prerelease tags
|
||||
# (e.g. v1.2.0-rc.1) are skipped so brew users stay on stable.
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v')
|
||||
&& !contains(github.ref_name, '-')
|
||||
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: netcatty-macos
|
||||
path: artifacts/
|
||||
|
||||
- name: Bump Cask in binaricat/homebrew-netcatty
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
ARTIFACTS_DIR: artifacts
|
||||
run: |
|
||||
# Strip the leading "v" — Cask version is plain semver.
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
export VERSION
|
||||
bash .github/scripts/bump-homebrew-cask.sh
|
||||
|
||||
82
App.tsx
82
App.tsx
@@ -28,7 +28,12 @@ import { upsertKnownHost } from './domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveHostTerminalThemeId,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
@@ -55,6 +60,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Input } from './components/ui/input';
|
||||
import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
|
||||
import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog';
|
||||
@@ -176,12 +182,22 @@ const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldMount) return;
|
||||
// Warm up the terminal layer shortly after first paint to reduce latency when opening a session.
|
||||
const id = window.setTimeout(() => setShouldMount(true), 1200);
|
||||
type IdleWindow = Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
cancelIdleCallback?: (id: number) => void;
|
||||
};
|
||||
const idleWindow = window as IdleWindow;
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
|
||||
return () => idleWindow.cancelIdleCallback?.(id);
|
||||
}
|
||||
const id = window.setTimeout(() => setShouldMount(true), 5000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [shouldMount]);
|
||||
|
||||
if (!shouldMount) return null;
|
||||
const shouldRender = shouldMount || isVisible;
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
@@ -300,6 +316,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
keysRef.current = keys;
|
||||
const knownHostsRef = useRef(knownHosts);
|
||||
knownHostsRef.current = knownHosts;
|
||||
// Bridge the gap while useVaultState hydrates: its async init awaits
|
||||
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
|
||||
// so the state is briefly [] at boot even when localStorage has entries.
|
||||
// Any SSH connect during that window (manual click or restored session)
|
||||
// would otherwise see no trusted hosts and prompt for fingerprint
|
||||
// re-confirmation. Mirrors the same fallback already used by sync payloads.
|
||||
const effectiveKnownHosts = useMemo(
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
|
||||
const {
|
||||
sessions,
|
||||
@@ -335,6 +361,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
setWorkspaceFocusedSession,
|
||||
reorderWorkspaceSessions,
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
@@ -1170,12 +1197,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const addConnectionLogRef = useRef(addConnectionLog);
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleSidePanelRef = useRef<(() => void) | null>(null);
|
||||
// Populated below so the hotkey dispatcher can open the Settings window
|
||||
// even though `handleOpenSettings` is declared further down in the file.
|
||||
const handleOpenSettingsRef = useRef<() => void>(() => {});
|
||||
const activeSidePanelTabRef = useRef<string | null>(null);
|
||||
const closeTabInFlightRef = useRef(false);
|
||||
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
|
||||
// dispatcher (defined outside that scope) can still reach the dirty-confirm
|
||||
@@ -1355,13 +1381,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
|
||||
const activeSidePanel = activeSidePanelTabRef.current;
|
||||
|
||||
const intent = resolveCloseIntent({
|
||||
activeTabId: currentId,
|
||||
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
|
||||
sessionForTab: session,
|
||||
activeSidePanelTab: activeSidePanel,
|
||||
focusIsInsideTerminal,
|
||||
});
|
||||
|
||||
@@ -1375,10 +1399,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (ok) closeSession(intent.sessionId);
|
||||
return;
|
||||
}
|
||||
case 'closeSidePanel': {
|
||||
closeSidePanelRef.current?.();
|
||||
return;
|
||||
}
|
||||
case 'closeWorkspace': {
|
||||
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
|
||||
const ok = await confirmIfBusyLocalTerminal(ids);
|
||||
@@ -1454,6 +1474,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setNavigateToSection('snippets');
|
||||
}
|
||||
break;
|
||||
case 'toggleSidePanel':
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Toggle broadcast mode for the active workspace
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
@@ -1704,6 +1727,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [updateSessionStatus, updateHostLastConnected]);
|
||||
|
||||
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
|
||||
updateHosts(hosts.map((h) => (
|
||||
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
|
||||
)));
|
||||
}, [hosts, updateHosts]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
@@ -1730,15 +1759,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
// Prefer the persisted sessionId because the session may already have been
|
||||
// removed from state by the time the terminal unmount cleanup runs.
|
||||
const matchingLog = connectionLogs
|
||||
.filter((log) => {
|
||||
if (log.endTime || log.terminalData) return false;
|
||||
if (log.sessionId) return log.sessionId === sessionId;
|
||||
return !!session && log.hostname === session.hostname;
|
||||
})
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
const matchingLog = selectConnectionLogForTerminalDataCapture(
|
||||
connectionLogs,
|
||||
{ sessionId, hostname: session?.hostname },
|
||||
);
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
|
||||
@@ -1996,11 +2020,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
customGroups={customGroups}
|
||||
knownHosts={knownHosts}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
shellHistory={shellHistory}
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessions={sessions}
|
||||
sessionCount={sessions.length}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
terminalThemeId={terminalThemeId}
|
||||
@@ -2069,7 +2093,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippetPackages={snippetPackages}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={knownHosts}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
draggingSessionId={draggingSessionId}
|
||||
terminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
@@ -2088,7 +2112,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onCloseSession={closeSession}
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
onUpdateHostDistro={updateHostDistro}
|
||||
onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))}
|
||||
onUpdateHost={handleUpdateHostFromTerminal}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
|
||||
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
|
||||
@@ -2103,6 +2127,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
@@ -2118,9 +2143,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
@@ -2426,7 +2450,9 @@ function AppWithProviders() {
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<ToastProvider>
|
||||
<App settings={settings} />
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<App settings={settings} />
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -264,6 +264,10 @@ const en: Messages = {
|
||||
'settings.terminal.theme.selectButton': 'Select Theme',
|
||||
'settings.terminal.theme.followApp': 'Follow Application Theme',
|
||||
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
|
||||
'settings.terminal.theme.darkTheme': 'Dark mode terminal theme',
|
||||
'settings.terminal.theme.lightTheme': 'Light mode terminal theme',
|
||||
'settings.terminal.theme.auto': 'Auto (match app theme)',
|
||||
'settings.terminal.theme.autoDesc': 'Follows the active UI theme preset',
|
||||
'settings.terminal.section.font': 'Font',
|
||||
'settings.terminal.section.cursor': 'Cursor',
|
||||
'settings.terminal.section.keyboard': 'Keyboard',
|
||||
@@ -301,6 +305,9 @@ const en: Messages = {
|
||||
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Use Option (Alt) as the Meta key instead of for special characters',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
@@ -323,6 +330,9 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
|
||||
'settings.terminal.behavior.forcePromptNewLine': 'Prompt on a new line',
|
||||
'settings.terminal.behavior.forcePromptNewLine.desc':
|
||||
'When the final line of command output is not terminated by a newline, move the recognized shell prompt to the next visual line.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
@@ -356,14 +366,21 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
|
||||
'settings.terminal.scrollback.desc': 'Limit number of terminal rows. Set to 0 for no limit.',
|
||||
'settings.terminal.scrollback.rows': 'Number of rows *',
|
||||
'settings.terminal.section.startupCommand': 'Startup command',
|
||||
'settings.terminal.startupCommandDelay.label': 'Startup command delay (ms)',
|
||||
'settings.terminal.startupCommandDelay.desc': 'How long to wait after connecting before sending the startup command. Also used between lines when the startup command has multiple lines. Increase for slow connections.',
|
||||
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
|
||||
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': 'Reset built-ins to defaults',
|
||||
'settings.terminal.keywordHighlight.resetBuiltIn': 'Restore default label and patterns',
|
||||
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
|
||||
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
|
||||
'settings.terminal.keywordHighlight.editBuiltIn': 'Edit Built-in Rule',
|
||||
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': 'Regex Pattern',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': 'Regex (e.g., \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternField': 'Regex Patterns',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': 'One regex per line (e.g., \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternHint': 'One regex per line. Patterns are matched case-insensitively with the global flag.',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'settings.terminal.keywordHighlight.preview': 'Preview',
|
||||
'settings.terminal.section.localShell': 'Local Shell',
|
||||
@@ -815,6 +832,11 @@ const en: Messages = {
|
||||
'sftp.transfers.collapseChildList': 'Hide',
|
||||
'sftp.transfers.retryAction': 'Retry',
|
||||
'sftp.transfers.dismissAction': 'Dismiss',
|
||||
'sftp.transfers.openTargetFolder': 'Open target folder',
|
||||
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
|
||||
'sftp.transfers.copyTargetPath': 'Copy target path',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
|
||||
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
|
||||
'sftp.transfers.resizeNameColumn': 'Resize file name column',
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
@@ -1128,10 +1150,23 @@ const en: Messages = {
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
'hostDetails.skipEcdsaHostKey': 'Skip ECDSA host key',
|
||||
'hostDetails.skipEcdsaHostKey.desc': 'Some old Huawei / Cisco switches produce non-standard ECDSA host-key signatures that cause "signature verification failed". Turning this on drops every ecdsa-sha2-* from the client offer so negotiation falls back to RSA / Ed25519.',
|
||||
'hostDetails.algorithms.advanced': 'Advanced algorithm overrides',
|
||||
'hostDetails.algorithms.advanced.desc': 'Replace the offered algorithm list for any category on a per-host basis. Leaving a category untouched uses the default; selecting a subset fully replaces the default list. Incorrect values can make the host unreachable.',
|
||||
'hostDetails.algorithms.inheritedNotice': 'The current group has algorithm overrides set for: {categories}. The "Reset" button here falls back to the group\'s lists, not NetCatty\'s defaults. To ignore the group restriction, clear the override in the group\'s algorithm settings.',
|
||||
'hostDetails.algorithms.customized': 'customized',
|
||||
'hostDetails.algorithms.reset': 'Reset',
|
||||
'hostDetails.algorithms.category.kex': 'Key Exchange (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': 'Cipher',
|
||||
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': 'Host Key',
|
||||
'hostDetails.algorithms.category.compress': 'Compression',
|
||||
'hostDetails.section.keepalive': 'Keepalive',
|
||||
'hostDetails.keepalive.override': 'Override global keepalive',
|
||||
'hostDetails.keepalive.desc': 'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
|
||||
@@ -1319,6 +1354,7 @@ const en: Messages = {
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
@@ -1877,6 +1913,18 @@ const en: Messages = {
|
||||
'ai.providers.remove': 'Remove',
|
||||
'ai.providers.name': 'Display Name',
|
||||
'ai.providers.name.placeholder': 'e.g. My Provider',
|
||||
'ai.providers.style': 'Protocol style',
|
||||
'ai.providers.style.anthropic': 'Anthropic-compatible',
|
||||
'ai.providers.style.openai': 'OpenAI-compatible',
|
||||
'ai.providers.style.google': 'Google-compatible',
|
||||
'ai.providers.style.inherited': 'auto',
|
||||
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
|
||||
'ai.providers.icon.change': 'Change icon',
|
||||
'ai.providers.icon.upload': 'Upload image',
|
||||
'ai.providers.icon.reset': 'Reset',
|
||||
'ai.providers.icon.close': 'Close',
|
||||
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Please choose an image file.',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
@@ -1922,13 +1970,20 @@ const en: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
'ai.claude.path': 'Path:',
|
||||
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.configSection': 'Authentication & config (optional)',
|
||||
'ai.claude.configDir': 'Config directory',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
|
||||
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
|
||||
'ai.claude.envVars': 'Environment variables',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
|
||||
'ai.claude.check': 'Check',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
@@ -1999,6 +2054,8 @@ const en: Messages = {
|
||||
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
|
||||
'ai.chat.placeholderDefault': 'Message Catty Agent...',
|
||||
'ai.chat.noModel': 'No model',
|
||||
'ai.chat.noProviderModel': 'No default model — set one in Settings → AI → Providers.',
|
||||
'ai.chat.selectProvider': 'Select provider',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
@@ -2054,6 +2111,37 @@ const en: Messages = {
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'ai.safety.blocklist.add': 'Add pattern',
|
||||
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Add Terminal',
|
||||
'terminal.layer.switchToSplitView': 'Switch to Split View',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.theme': 'Theme',
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'topTabs.openQuickSwitcher': 'Open quick switcher',
|
||||
'topTabs.moreTabs': 'More tabs',
|
||||
'topTabs.aiAssistant': 'AI Assistant',
|
||||
'topTabs.toggleTheme': 'Toggle theme',
|
||||
'topTabs.openSettings': 'Open Settings',
|
||||
'ai.chat.sessionHistory': 'Session history',
|
||||
'ai.chat.attach': 'Attach',
|
||||
'ai.chat.collapse': 'Collapse',
|
||||
'ai.chat.expand': 'Expand',
|
||||
'ai.chat.enableAgent': 'Enable {name}',
|
||||
'zmodem.waitingForRemote': 'Waiting for remote...',
|
||||
'zmodem.uploading': 'Uploading',
|
||||
'zmodem.downloading': 'Downloading',
|
||||
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
2180
application/i18n/locales/ru.ts
Normal file
2180
application/i18n/locales/ru.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -586,6 +586,11 @@ const zhCN: Messages = {
|
||||
'sftp.transfers.collapseChildList': '收起',
|
||||
'sftp.transfers.retryAction': '重试',
|
||||
'sftp.transfers.dismissAction': '移除',
|
||||
'sftp.transfers.openTargetFolder': '打开目标目录',
|
||||
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
|
||||
'sftp.transfers.copyTargetPath': '复制目标路径',
|
||||
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
|
||||
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
|
||||
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
@@ -744,10 +749,23 @@ const zhCN: Messages = {
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.skipEcdsaHostKey': '跳过 ECDSA 主机密钥',
|
||||
'hostDetails.skipEcdsaHostKey.desc': '某些老款华为 / 思科交换机的 ECDSA 主机密钥签名不规范,会导致连接报 "signature verification failed"。开启后客户端不再 advertise ecdsa-sha2-*,强制使用 RSA / Ed25519。',
|
||||
'hostDetails.algorithms.advanced': '高级算法配置',
|
||||
'hostDetails.algorithms.advanced.desc': '针对单个 host 自定义各分类的算法清单。不勾选 = 使用默认;勾选子集后将完全替换默认列表。配置错误可能导致无法连接。',
|
||||
'hostDetails.algorithms.inheritedNotice': '当前组已设置以下分类的算法 override:{categories}。本面板的"恢复默认"只会回到组的设置,而不是 NetCatty 默认列表。若要忽略组的限制,请到组的算法设置里取消。',
|
||||
'hostDetails.algorithms.customized': '已自定义',
|
||||
'hostDetails.algorithms.reset': '恢复默认',
|
||||
'hostDetails.algorithms.category.kex': '密钥交换 (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': '加密算法 (Cipher)',
|
||||
'hostDetails.algorithms.category.hmac': '完整性算法 (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': '主机密钥 (Host Key)',
|
||||
'hostDetails.algorithms.category.compress': '压缩 (Compression)',
|
||||
'hostDetails.section.keepalive': '会话保活',
|
||||
'hostDetails.keepalive.override': '为此主机单独配置',
|
||||
'hostDetails.keepalive.desc': '为该主机使用专属的保活策略,而不是跟随全局设置。适用于不响应 keepalive@openssh.com 请求的老旧路由器 / 交换机——将间隔设为 0 可对该主机彻底关闭保活。',
|
||||
@@ -900,6 +918,7 @@ const zhCN: Messages = {
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.pasteSelection': '粘贴选中文本',
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.reconnect': '重新连接',
|
||||
'terminal.menu.splitHorizontal': '水平分屏',
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
@@ -1403,6 +1422,10 @@ const zhCN: Messages = {
|
||||
'settings.terminal.theme.selectButton': '选择主题',
|
||||
'settings.terminal.theme.followApp': '跟随应用主题',
|
||||
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
|
||||
'settings.terminal.theme.darkTheme': '深色模式终端主题',
|
||||
'settings.terminal.theme.lightTheme': '浅色模式终端主题',
|
||||
'settings.terminal.theme.auto': '自动(跟随界面主题)',
|
||||
'settings.terminal.theme.autoDesc': '跟随当前界面主题预设',
|
||||
'settings.terminal.section.font': '字体',
|
||||
'settings.terminal.section.cursor': '光标',
|
||||
'settings.terminal.section.keyboard': '键盘',
|
||||
@@ -1439,6 +1462,8 @@ const zhCN: Messages = {
|
||||
'settings.terminal.cursor.blink': '光标闪烁',
|
||||
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
|
||||
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f,让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C)',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右键行为',
|
||||
@@ -1459,6 +1484,9 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
|
||||
'settings.terminal.behavior.forcePromptNewLine': '提示符另起一行',
|
||||
'settings.terminal.behavior.forcePromptNewLine.desc':
|
||||
'当命令输出的最后一行未以换行符结束时,将识别到的 shell 提示符移动到下一行显示。',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
@@ -1488,14 +1516,21 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
|
||||
'settings.terminal.scrollback.desc': '限制终端行数。设为 0 表示不限制。',
|
||||
'settings.terminal.scrollback.rows': '行数 *',
|
||||
'settings.terminal.section.startupCommand': '启动命令',
|
||||
'settings.terminal.startupCommandDelay.label': '启动命令延迟(毫秒)',
|
||||
'settings.terminal.startupCommandDelay.desc': '连接建立后等待多久再发送启动命令;启动命令为多行时,行与行之间也使用该间隔。慢连接可调大。',
|
||||
'settings.terminal.keywordHighlight.title': '关键字高亮',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
|
||||
'settings.terminal.keywordHighlight.resetDefaults': '把内置规则恢复为默认',
|
||||
'settings.terminal.keywordHighlight.resetBuiltIn': '恢复内置标签与正则',
|
||||
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
|
||||
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
|
||||
'settings.terminal.keywordHighlight.editBuiltIn': '编辑内置规则',
|
||||
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': '正则表达式',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': '正则表达式(如 \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': '每行一个正则(如 \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.patternHint': '每行一个正则。匹配忽略大小写,全局匹配。',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
|
||||
'settings.terminal.keywordHighlight.preview': '预览',
|
||||
'settings.terminal.section.localShell': '本地 Shell',
|
||||
@@ -1584,6 +1619,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作区',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
|
||||
@@ -1886,6 +1922,18 @@ const zhCN: Messages = {
|
||||
'ai.providers.remove': '移除',
|
||||
'ai.providers.name': '显示名称',
|
||||
'ai.providers.name.placeholder': '例如 我的提供商',
|
||||
'ai.providers.style': '协议风格',
|
||||
'ai.providers.style.anthropic': 'Anthropic 兼容',
|
||||
'ai.providers.style.openai': 'OpenAI 兼容',
|
||||
'ai.providers.style.google': 'Google 兼容',
|
||||
'ai.providers.style.inherited': '默认',
|
||||
'ai.providers.style.help': '决定请求使用哪种 API 格式。当第三方端点的协议与其提供商类型不一致时,可手动覆盖。',
|
||||
'ai.providers.icon.change': '修改图标',
|
||||
'ai.providers.icon.upload': '上传图片',
|
||||
'ai.providers.icon.reset': '恢复默认',
|
||||
'ai.providers.icon.close': '收起',
|
||||
'ai.providers.icon.uploadedNote': '自定义图标(64×64 WebP)',
|
||||
'ai.providers.icon.errorType': '请选择图片文件。',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
@@ -1931,13 +1979,20 @@ const zhCN: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。需要系统中已安装 Claude Code CLI。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
'ai.claude.path': '路径:',
|
||||
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.configSection': '认证与配置(可选)',
|
||||
'ai.claude.configDir': '配置目录',
|
||||
'ai.claude.configDir.placeholder': '~/.claude(留空用默认)',
|
||||
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
|
||||
'ai.claude.envVars': '环境变量',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': '每行一个 KEY=VALUE,传给 Claude agent。明文存在本地——API key/凭据建议用上面的「配置目录」(claude 登录),不要放这里。',
|
||||
'ai.claude.check': '检查',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
@@ -2008,6 +2063,8 @@ const zhCN: Messages = {
|
||||
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
|
||||
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
|
||||
'ai.chat.noModel': '未选择模型',
|
||||
'ai.chat.noProviderModel': '未配置默认模型——前往 设置 → AI → 提供商 设置。',
|
||||
'ai.chat.selectProvider': '选择提供商',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
@@ -2063,6 +2120,37 @@ const zhCN: Messages = {
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行;ACP Agent 可能有自己的内部控制。',
|
||||
|
||||
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
|
||||
'terminal.layer.addTerminal': '添加终端',
|
||||
'terminal.layer.switchToSplitView': '切换到分屏视图',
|
||||
'terminal.layer.sftp': '文件传输',
|
||||
'terminal.layer.scripts': '脚本',
|
||||
'terminal.layer.theme': '主题',
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'topTabs.openQuickSwitcher': '打开快速切换',
|
||||
'topTabs.moreTabs': '更多标签页',
|
||||
'topTabs.aiAssistant': 'AI 助手',
|
||||
'topTabs.toggleTheme': '切换主题',
|
||||
'topTabs.openSettings': '打开设置',
|
||||
'ai.chat.sessionHistory': '会话历史',
|
||||
'ai.chat.attach': '附件',
|
||||
'ai.chat.collapse': '收起',
|
||||
'ai.chat.expand': '展开',
|
||||
'ai.chat.enableAgent': '启用 {name}',
|
||||
'zmodem.waitingForRemote': '等待远端...',
|
||||
'zmodem.uploading': '上传中',
|
||||
'zmodem.downloading': '下载中',
|
||||
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
|
||||
'zmodem.overwrite.title': '远端已存在同名文件',
|
||||
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
|
||||
'zmodem.overwrite.overwrite': '覆盖',
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import en, { type Messages } from './locales/en';
|
||||
import zhCN from './locales/zh-CN';
|
||||
import ru from './locales/ru';
|
||||
|
||||
// Keep keys stable; add new locales by adding another import and map entry.
|
||||
export { type Messages };
|
||||
|
||||
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
|
||||
en,
|
||||
ru,
|
||||
'zh-CN': zhCN,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback,useSyncExternalStore } from 'react';
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
// Simple store for active tab that allows fine-grained subscriptions
|
||||
type Listener = () => void;
|
||||
@@ -92,7 +92,11 @@ export const useIsEditorTabActive = (tabId: string): boolean => {
|
||||
// Check if terminal layer should be visible
|
||||
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
|
||||
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
const getSnapshot = useCallback(() => {
|
||||
const activeTabId = activeTabStore.getActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
}, [draggingSessionId]);
|
||||
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
20
application/state/aiStateEvents.ts
Normal file
20
application/state/aiStateEvents.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Same-window AI-state-changed event plumbing.
|
||||
*
|
||||
* `localStorage` writes only emit `storage` events in *other* windows; the
|
||||
* window doing the write never gets notified. That's a problem for code
|
||||
* that mutates AI storage outside of `useAIState`'s setters (e.g. sync
|
||||
* apply): without a manual nudge, mounted components keep showing stale
|
||||
* AI state until reload.
|
||||
*
|
||||
* Both the dispatcher and `useAIState`'s listener live here so non-React
|
||||
* call sites (sync, IPC handlers, etc.) can fire the event without
|
||||
* pulling in the hook.
|
||||
*/
|
||||
|
||||
export const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
|
||||
export function emitAIStateChanged(key: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
}
|
||||
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
test("runtime remote checks wait for the startup check to finish", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: false,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks run immediately after startup gate opens", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks respect the minimum interval", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 40_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("forced runtime remote checks bypass only the interval gate", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
force: true,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
isSyncing: true,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
|
||||
});
|
||||
35
application/state/autoSyncRemoteSchedule.ts
Normal file
35
application/state/autoSyncRemoteSchedule.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
|
||||
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
|
||||
|
||||
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
|
||||
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
|
||||
return Math.max(
|
||||
MIN_RUNTIME_REMOTE_CHECK_MS,
|
||||
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RuntimeRemoteCheckInput {
|
||||
hasAnyConnectedProvider: boolean;
|
||||
autoSyncEnabled: boolean;
|
||||
isUnlocked: boolean;
|
||||
startupRemoteCheckDone: boolean;
|
||||
isSyncing: boolean;
|
||||
isSyncRunning: boolean;
|
||||
remoteCheckInFlight: boolean;
|
||||
force?: boolean;
|
||||
now: number;
|
||||
lastRemoteCheckAt: number | null;
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
|
||||
if (!input.hasAnyConnectedProvider) return false;
|
||||
if (!input.autoSyncEnabled) return false;
|
||||
if (!input.isUnlocked) return false;
|
||||
if (!input.startupRemoteCheckDone) return false;
|
||||
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
|
||||
if (input.force === true) return true;
|
||||
if (input.lastRemoteCheckAt == null) return true;
|
||||
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
|
||||
}
|
||||
@@ -244,16 +244,3 @@ export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useEditorDirty = (id: EditorTabId): boolean => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useAnyEditorDirty = (): boolean => {
|
||||
const getSnapshot = useCallback(
|
||||
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
|
||||
[],
|
||||
);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
@@ -3,33 +3,27 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
|
||||
|
||||
const baseWorkspace = {
|
||||
id: "w1",
|
||||
focusedSessionId: "s1",
|
||||
};
|
||||
|
||||
const baseWorkspace = { id: "w1", focusedSessionId: "s1" };
|
||||
const baseSession = { id: "s1" };
|
||||
|
||||
test("non-workspace tab → closeSingleTab with session id", () => {
|
||||
const result = resolveCloseIntent({
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: baseSession,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
|
||||
test("non-workspace session tab → closeSingleTab even when focus is outside the terminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: { id: "s1" },
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("vault/sftp tab → noop", () => {
|
||||
@@ -37,74 +31,37 @@ test("vault/sftp tab → noop", () => {
|
||||
activeTabId: "vault",
|
||||
workspace: null,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "noop" });
|
||||
});
|
||||
|
||||
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
|
||||
test("workspace + focus in terminal → closeTerminal (side panel no longer intercepts)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "sftp",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
|
||||
test("workspace + focus NOT in terminal → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
|
||||
test("workspace with no focused session → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export type CloseIntent =
|
||||
| { kind: 'closeTerminal'; sessionId: string }
|
||||
| { kind: 'closeSidePanel' }
|
||||
| { kind: 'closeWorkspace'; workspaceId: string }
|
||||
| { kind: 'closeSingleTab'; sessionId: string }
|
||||
| { kind: 'noop' };
|
||||
@@ -9,22 +8,14 @@ export interface ResolveCloseInput {
|
||||
activeTabId: string | null;
|
||||
workspace: { id: string; focusedSessionId?: string } | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
activeSidePanelTab: string | null;
|
||||
focusIsInsideTerminal: boolean;
|
||||
}
|
||||
|
||||
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
|
||||
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
|
||||
const { activeTabId, workspace, sessionForTab, focusIsInsideTerminal } = input;
|
||||
|
||||
if (!activeTabId) return { kind: 'noop' };
|
||||
|
||||
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
|
||||
// Modals take priority over this but are intercepted upstream in App.tsx before the
|
||||
// hotkey reaches resolveCloseIntent.
|
||||
if (activeSidePanelTab !== null) {
|
||||
return { kind: 'closeSidePanel' };
|
||||
}
|
||||
|
||||
if (sessionForTab && !workspace) {
|
||||
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
|
||||
}
|
||||
|
||||
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal file
19
application/state/resolveSidePanelToggleIntent.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveSidePanelToggleIntent } from "./resolveSidePanelToggleIntent.ts";
|
||||
|
||||
test("open: closed with a remembered tab → open that tab", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: "sftp", fallbackTab: "scripts" });
|
||||
assert.deepEqual(r, { kind: "open", tab: "sftp" });
|
||||
});
|
||||
|
||||
test("open: closed with no memory → open the fallback tab", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: null, fallbackTab: "scripts" });
|
||||
assert.deepEqual(r, { kind: "open", tab: "scripts" });
|
||||
});
|
||||
|
||||
test("close: already open → close", () => {
|
||||
const r = resolveSidePanelToggleIntent({ isOpen: true, lastTab: "theme", fallbackTab: "sftp" });
|
||||
assert.deepEqual(r, { kind: "close" });
|
||||
});
|
||||
18
application/state/resolveSidePanelToggleIntent.ts
Normal file
18
application/state/resolveSidePanelToggleIntent.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type SidePanelToggleIntent<T extends string> =
|
||||
| { kind: 'close' }
|
||||
| { kind: 'open'; tab: T };
|
||||
|
||||
/**
|
||||
* Decide what the "toggle side panel" shortcut should do.
|
||||
* - If a panel is open → close it.
|
||||
* - If closed → reopen the last-shown sub-panel for the tab, falling back to
|
||||
* `fallbackTab` when the tab has no remembered panel.
|
||||
*/
|
||||
export function resolveSidePanelToggleIntent<T extends string>(input: {
|
||||
isOpen: boolean;
|
||||
lastTab: T | null;
|
||||
fallbackTab: T;
|
||||
}): SidePanelToggleIntent<T> {
|
||||
if (input.isOpen) return { kind: 'close' };
|
||||
return { kind: 'open', tab: input.lastTab ?? input.fallbackTab };
|
||||
}
|
||||
32
application/state/resolveTerminalSessionExitIntent.test.ts
Normal file
32
application/state/resolveTerminalSessionExitIntent.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend timeout events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "timeout", error: "idle timeout" }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend error events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
22
application/state/resolveTerminalSessionExitIntent.ts
Normal file
22
application/state/resolveTerminalSessionExitIntent.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type TerminalSessionExitEvent = {
|
||||
exitCode?: number;
|
||||
signal?: number;
|
||||
error?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed";
|
||||
};
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
evt: TerminalSessionExitEvent,
|
||||
): TerminalSessionExitIntent {
|
||||
if (evt.reason === "exited") {
|
||||
return { kind: "closeSession" };
|
||||
}
|
||||
|
||||
// Timeouts, transport errors, and channel closes should keep the tab visible
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
23
application/state/sftp/bookmarkHelpers.ts
Normal file
23
application/state/sftp/bookmarkHelpers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
|
||||
const ROOT_PATH_RE = /^[A-Za-z]:[\\/]?$/;
|
||||
|
||||
export function getSftpBookmarkLabel(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed === "/" || ROOT_PATH_RE.test(trimmed)) return trimmed;
|
||||
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed;
|
||||
}
|
||||
|
||||
export function createSftpBookmark(
|
||||
path: string,
|
||||
options: { global?: boolean; idPrefix?: string } = {},
|
||||
): SftpBookmark {
|
||||
const global = options.global === true;
|
||||
const idPrefix = options.idPrefix ?? (global ? "gbm" : "bm");
|
||||
return {
|
||||
id: `${idPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path,
|
||||
label: getSftpBookmarkLabel(path),
|
||||
...(global ? { global: true } : {}),
|
||||
};
|
||||
}
|
||||
45
application/state/sftp/globalSftpBookmarks.ts
Normal file
45
application/state/sftp/globalSftpBookmarks.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
|
||||
export function subscribeGlobalSftpBookmarks(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getGlobalSftpBookmarksSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function rehydrateGlobalSftpBookmarks() {
|
||||
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function setGlobalSftpBookmarks(
|
||||
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
|
||||
) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
|
||||
for (const listener of listeners) listener();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("sftp-bookmarks-changed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
|
||||
rehydrateGlobalSftpBookmarks();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -113,6 +113,9 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: jumpKeyAuth.identityFilePaths,
|
||||
keepaliveInterval: hopKeepalive.interval,
|
||||
keepaliveCountMax: hopKeepalive.countMax,
|
||||
legacyAlgorithms: jumpHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: jumpHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: jumpHost.algorithms,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -159,6 +162,13 @@ export const buildSftpHostCredentials = ({
|
||||
identityFilePaths: keyAuth.identityFilePaths,
|
||||
keepaliveInterval: targetKeepalive.interval,
|
||||
keepaliveCountMax: targetKeepalive.countMax,
|
||||
// Algorithm settings — must reach the SFTP bridge or hosts that need
|
||||
// legacy mode / the ECDSA skip / advanced overrides would still hit
|
||||
// the original negotiation failure when opening their SFTP pane,
|
||||
// even though the terminal session works.
|
||||
legacyAlgorithms: host.legacyAlgorithms,
|
||||
skipEcdsaHostKey: host.skipEcdsaHostKey,
|
||||
algorithmOverrides: host.algorithms,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
11
application/state/sftp/utils.test.ts
Normal file
11
application/state/sftp/utils.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { isConcreteTransferTargetPath } from "./utils";
|
||||
|
||||
test("concrete transfer target paths exclude temporary placeholders", () => {
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: "/Users/alice/Downloads/report.pdf" }), true);
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: "C:\\Users\\alice\\Downloads\\report.pdf" }), true);
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: "(temp)" }), false);
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: " " }), false);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SftpFileEntry } from "../../../domain/models";
|
||||
import { SftpFileEntry, TransferTask } from "../../../domain/models";
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "--";
|
||||
@@ -76,6 +76,11 @@ export const getParentPath = (path: string): string => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const isConcreteTransferTargetPath = (task: Pick<TransferTask, "targetPath">): boolean => {
|
||||
const targetPath = task.targetPath.trim();
|
||||
return targetPath.length > 0 && targetPath !== "(temp)";
|
||||
};
|
||||
|
||||
export const getFileName = (path: string): string => {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || "";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
@@ -61,17 +62,14 @@ function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: AIBridge }).netcatty;
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
|
||||
const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
|
||||
const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
function emitAIStateChanged(key: string) {
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
}
|
||||
|
||||
function cleanupAcpSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
@@ -326,6 +324,20 @@ export function useAIState() {
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
|
||||
);
|
||||
// Per-agent provider override: remembers which provider config each agent
|
||||
// should bind to. Falls back to the global `activeProviderId` when an agent
|
||||
// has no entry. Used so that e.g. Catty Agent can stay on DeepSeek while
|
||||
// a Claude/Codex run continues on its existing provider.
|
||||
const [agentProviderMap, setAgentProviderMapRaw] = useState<Record<string, string>>(() =>
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {}
|
||||
);
|
||||
// Mirror for non-functional reads inside removeProvider — needed to know
|
||||
// which agents were bound to the deleted provider so we can also drop
|
||||
// their saved model ids (those ids belonged to the now-missing provider).
|
||||
const agentProviderMapRef = useRef(agentProviderMap);
|
||||
useEffect(() => {
|
||||
agentProviderMapRef.current = agentProviderMap;
|
||||
}, [agentProviderMap]);
|
||||
|
||||
// ── Web Search Config ──
|
||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||
@@ -413,6 +425,21 @@ export function useAIState() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAgentProvider = useCallback((agentId: string, providerId: string) => {
|
||||
setAgentProviderMapRaw(prev => {
|
||||
// Empty string clears the per-agent override and lets the agent fall
|
||||
// back to the global `activeProviderId`.
|
||||
const next = { ...prev };
|
||||
if (providerId) {
|
||||
next[agentId] = providerId;
|
||||
} else {
|
||||
delete next[agentId];
|
||||
}
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||
setWebSearchConfigRaw(config);
|
||||
if (config) {
|
||||
@@ -600,6 +627,9 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_AGENT_PROVIDER_MAP:
|
||||
setAgentProviderMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
|
||||
const nextActiveSessionIdMap =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
@@ -1080,6 +1110,41 @@ export function useAIState() {
|
||||
}
|
||||
return prevId;
|
||||
});
|
||||
// Drop per-agent overrides pointing at this provider plus the saved
|
||||
// model id for those agents — the id belonged to the now-missing
|
||||
// provider, so feeding it to the fallback provider would just send
|
||||
// a model name that target doesn't recognize.
|
||||
const orphanedAgents = Object.keys(agentProviderMapRef.current)
|
||||
.filter((agentId) => agentProviderMapRef.current[agentId] === id);
|
||||
if (orphanedAgents.length > 0) {
|
||||
setAgentProviderMapRaw(prev => {
|
||||
const next: Record<string, string> = {};
|
||||
let changed = false;
|
||||
for (const agentId of Object.keys(prev)) {
|
||||
if (prev[agentId] === id) {
|
||||
changed = true;
|
||||
} else {
|
||||
next[agentId] = prev[agentId];
|
||||
}
|
||||
}
|
||||
if (!changed) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
|
||||
return next;
|
||||
});
|
||||
setAgentModelMapRaw(prev => {
|
||||
let changed = false;
|
||||
const next: Record<string, string> = { ...prev };
|
||||
for (const agentId of orphanedAgents) {
|
||||
if (agentId in next) {
|
||||
delete next[agentId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [setProviders]);
|
||||
|
||||
// ── Computed ──
|
||||
@@ -1123,6 +1188,9 @@ export function useAIState() {
|
||||
// Per-agent model memory
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
// Per-agent provider override (falls back to activeProviderId when unset)
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
|
||||
// Web search
|
||||
webSearchConfig,
|
||||
|
||||
@@ -52,14 +52,19 @@ export function useAgentDiscovery(
|
||||
);
|
||||
if (!match) return ea;
|
||||
|
||||
// Check if args or ACP config differ
|
||||
// Check if args, ACP config, or Claude's resolved system path differ
|
||||
const currentArgs = JSON.stringify(ea.args || []);
|
||||
const newArgs = JSON.stringify(match.args);
|
||||
const acpChanged = ea.acpCommand !== match.acpCommand
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
|
||||
if (currentArgs !== newArgs || acpChanged) {
|
||||
const env = match.command === 'claude'
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
|
||||
: ea.env;
|
||||
const envChanged = match.command === 'claude'
|
||||
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
|
||||
if (currentArgs !== newArgs || acpChanged || envChanged) {
|
||||
changed = true;
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
|
||||
}
|
||||
return ea;
|
||||
});
|
||||
@@ -86,6 +91,7 @@ export function useAgentDiscovery(
|
||||
enabled: true,
|
||||
acpCommand: agent.acpCommand,
|
||||
acpArgs: agent.acpArgs,
|
||||
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
|
||||
};
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -16,14 +16,15 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectSyncableSettings,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
hasMeaningfulCloudSyncData,
|
||||
} from '../syncPayload';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
@@ -31,6 +32,10 @@ import {
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { notify } from '../notification';
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -95,6 +100,11 @@ interface SyncNowOptions {
|
||||
trigger?: SyncTrigger;
|
||||
}
|
||||
|
||||
interface RemoteVersionCheckOptions {
|
||||
force?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
}
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const sync = useCloudSync();
|
||||
@@ -156,21 +166,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}, []);
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
if (!effectivePFRules || effectivePFRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
effectivePFRules = stored.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
@@ -179,7 +174,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
}, [
|
||||
@@ -417,17 +412,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// windows but does NOT serialize same-window re-entry, so this
|
||||
// in-flight guard closes that gap at the top of the call.
|
||||
const checkRemoteInFlightRef = useRef(false);
|
||||
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const force = options?.force === true;
|
||||
const notifyOnFailure = options?.notifyOnFailure !== false;
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
|
||||
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -509,7 +507,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
@@ -563,14 +560,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
if (notifyOnFailure) {
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
}
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
// provider/unlock transition) can retry.
|
||||
} finally {
|
||||
@@ -741,12 +740,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
if (!shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
|
||||
autoSyncEnabled: sync.autoSyncEnabled,
|
||||
isUnlocked: sync.isUnlocked,
|
||||
startupRemoteCheckDone: remoteCheckDoneRef.current,
|
||||
isSyncing: sync.isSyncing,
|
||||
isSyncRunning: isSyncRunningRef.current,
|
||||
remoteCheckInFlight: checkRemoteInFlightRef.current,
|
||||
force: options?.force === true,
|
||||
now,
|
||||
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
|
||||
minIntervalMs,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRuntimeRemoteCheckAtRef.current = now;
|
||||
await checkRemoteVersion({ force: true, notifyOnFailure: false });
|
||||
}, [
|
||||
checkRemoteVersion,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isSyncing,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Keep checking the cloud while the app is open. This closes the gap where
|
||||
// another device uploads changes after our startup inspection but before
|
||||
// this device edits anything locally.
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
const timerId = window.setInterval(() => {
|
||||
void runRuntimeRemoteCheck();
|
||||
}, intervalMs);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [
|
||||
runRuntimeRemoteCheck,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Also re-check when the user returns to the app or the network comes back.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
}
|
||||
};
|
||||
const handleOnline = () => {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, [runRuntimeRemoteCheck]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider) {
|
||||
hasCheckedRemoteRef.current = false;
|
||||
remoteCheckDoneRef.current = false;
|
||||
lastRuntimeRemoteCheckAtRef.current = null;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
|
||||
@@ -1,41 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
|
||||
interface HotkeyActions {
|
||||
// Tab management
|
||||
switchToTab: (tabIndex: number) => void;
|
||||
nextTab: () => void;
|
||||
prevTab: () => void;
|
||||
closeTab: () => void;
|
||||
newTab: () => void;
|
||||
|
||||
// Navigation
|
||||
openHosts: () => void;
|
||||
openSftp: () => void;
|
||||
quickSwitch: () => void;
|
||||
newWorkspace: () => void;
|
||||
commandPalette: () => void;
|
||||
portForwarding: () => void;
|
||||
snippets: () => void;
|
||||
|
||||
// Terminal actions (handled per-terminal)
|
||||
copy: () => void;
|
||||
paste: () => void;
|
||||
selectAll: () => void;
|
||||
clearBuffer: () => void;
|
||||
searchTerminal: () => void;
|
||||
|
||||
// Workspace/split actions
|
||||
splitHorizontal: () => void;
|
||||
splitVertical: () => void;
|
||||
moveFocus: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
||||
|
||||
// App features
|
||||
broadcast: () => void;
|
||||
openLocal: () => void;
|
||||
openSettings: () => void;
|
||||
}
|
||||
|
||||
// Check if keyboard event matches our app-level shortcuts
|
||||
// Returns the matched binding action or null
|
||||
export const checkAppShortcut = (
|
||||
@@ -87,163 +51,3 @@ export const getTerminalPassthroughActions = (): Set<string> => {
|
||||
'searchTerminal',
|
||||
]);
|
||||
};
|
||||
|
||||
interface UseGlobalHotkeysOptions {
|
||||
hotkeyScheme: 'disabled' | 'mac' | 'pc';
|
||||
keyBindings: KeyBinding[];
|
||||
actions: Partial<HotkeyActions>;
|
||||
orderedTabs: string[];
|
||||
sessions: { id: string }[];
|
||||
workspaces: { id: string }[];
|
||||
isSettingsOpen?: boolean;
|
||||
}
|
||||
|
||||
export const useGlobalHotkeys = ({
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
actions,
|
||||
orderedTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
isSettingsOpen = false,
|
||||
}: UseGlobalHotkeysOptions) => {
|
||||
const actionsRef = useRef(actions);
|
||||
actionsRef.current = actions;
|
||||
|
||||
const orderedTabsRef = useRef(orderedTabs);
|
||||
orderedTabsRef.current = orderedTabs;
|
||||
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
|
||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (hotkeyScheme === 'disabled') return;
|
||||
if (isSettingsOpen) return; // Don't handle hotkeys when settings is open
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const appLevelActions = getAppLevelActions();
|
||||
|
||||
// Check if this is an app-level shortcut
|
||||
const matched = checkAppShortcut(e, keyBindings, isMac);
|
||||
if (!matched) return;
|
||||
|
||||
const { action, binding: _binding } = matched;
|
||||
|
||||
// Only handle app-level actions here
|
||||
// Terminal-level actions are handled by the terminal itself
|
||||
if (!appLevelActions.has(action)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const currentActions = actionsRef.current;
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
currentActions.switchToTab?.(num);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'nextTab':
|
||||
currentActions.nextTab?.();
|
||||
break;
|
||||
case 'prevTab':
|
||||
currentActions.prevTab?.();
|
||||
break;
|
||||
case 'closeTab':
|
||||
currentActions.closeTab?.();
|
||||
break;
|
||||
case 'newTab':
|
||||
currentActions.newTab?.();
|
||||
break;
|
||||
case 'openHosts':
|
||||
currentActions.openHosts?.();
|
||||
break;
|
||||
case 'openSftp':
|
||||
currentActions.openSftp?.();
|
||||
break;
|
||||
case 'openLocal':
|
||||
currentActions.openLocal?.();
|
||||
break;
|
||||
case 'quickSwitch':
|
||||
currentActions.quickSwitch?.();
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
currentActions.newWorkspace?.();
|
||||
break;
|
||||
case 'commandPalette':
|
||||
currentActions.commandPalette?.();
|
||||
break;
|
||||
case 'portForwarding':
|
||||
currentActions.portForwarding?.();
|
||||
break;
|
||||
case 'snippets':
|
||||
currentActions.snippets?.();
|
||||
break;
|
||||
case 'splitHorizontal':
|
||||
currentActions.splitHorizontal?.();
|
||||
break;
|
||||
case 'splitVertical':
|
||||
currentActions.splitVertical?.();
|
||||
break;
|
||||
case 'moveFocus': {
|
||||
// Determine direction from arrow key
|
||||
const key = e.key;
|
||||
if (key === 'ArrowUp') currentActions.moveFocus?.('up');
|
||||
else if (key === 'ArrowDown') currentActions.moveFocus?.('down');
|
||||
else if (key === 'ArrowLeft') currentActions.moveFocus?.('left');
|
||||
else if (key === 'ArrowRight') currentActions.moveFocus?.('right');
|
||||
break;
|
||||
}
|
||||
case 'broadcast':
|
||||
currentActions.broadcast?.();
|
||||
break;
|
||||
case 'openSettings':
|
||||
currentActions.openSettings?.();
|
||||
break;
|
||||
}
|
||||
}, [hotkeyScheme, keyBindings, isSettingsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// Use capture phase to intercept before xterm
|
||||
window.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
}, [handleGlobalKeyDown]);
|
||||
};
|
||||
|
||||
// Helper to create key event handler for xterm's attachCustomKeyEventHandler
|
||||
// Returns false to let xterm handle the key, true to prevent xterm from handling
|
||||
export const createXtermKeyHandler = (
|
||||
keyBindings: KeyBinding[],
|
||||
isMac: boolean,
|
||||
onTerminalAction?: (action: string, e: KeyboardEvent) => void
|
||||
) => {
|
||||
const appLevelActions = getAppLevelActions();
|
||||
const terminalActions = getTerminalPassthroughActions();
|
||||
|
||||
return (e: KeyboardEvent): boolean => {
|
||||
const matched = checkAppShortcut(e, keyBindings, isMac);
|
||||
if (!matched) return true; // Let xterm handle it
|
||||
|
||||
const { action } = matched;
|
||||
|
||||
// App-level actions: prevent xterm from handling, let global handler take over
|
||||
if (appLevelActions.has(action)) {
|
||||
return false; // Don't let xterm handle, will bubble to global handler
|
||||
}
|
||||
|
||||
// Terminal-level actions: handle here and prevent default
|
||||
if (terminalActions.has(action)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onTerminalAction?.(action, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // Let xterm handle other keys
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,6 +17,13 @@ export const useKeychainBackend = () => {
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
sessionId?: string;
|
||||
// Algorithm settings — let the keychain "export public key" flow honor
|
||||
// the same per-host SSH algorithm config the terminal uses, so a host
|
||||
// that needs the ECDSA skip / legacy mode / advanced overrides works
|
||||
// here too.
|
||||
legacyAlgorithms?: boolean;
|
||||
skipEcdsaHostKey?: boolean;
|
||||
algorithmOverrides?: import("../../domain/models").HostAlgorithmOverrides;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
|
||||
|
||||
@@ -9,6 +9,7 @@ FocusDirection,
|
||||
getNextFocusSessionId,
|
||||
insertPaneIntoWorkspace,
|
||||
pruneWorkspaceNode,
|
||||
reorderWorkspaceFocusSessionOrder,
|
||||
SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
@@ -759,6 +760,27 @@ export const useSessionState = () => {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const reorderWorkspaceSessions = useCallback((
|
||||
workspaceId: string,
|
||||
draggedSessionId: string,
|
||||
targetSessionId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
) => {
|
||||
setWorkspaces(prev => prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return {
|
||||
...ws,
|
||||
focusSessionOrder: reorderWorkspaceFocusSessionOrder(
|
||||
ws.root,
|
||||
ws.focusSessionOrder,
|
||||
draggedSessionId,
|
||||
targetSessionId,
|
||||
position,
|
||||
),
|
||||
};
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Move focus between panes in a workspace
|
||||
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
|
||||
const workspace = workspaces.find(w => w.id === workspaceId);
|
||||
@@ -1049,6 +1071,7 @@ export const useSessionState = () => {
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
setWorkspaceFocusedSession,
|
||||
reorderWorkspaceSessions,
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -49,7 +51,7 @@ import {
|
||||
shouldApplyIncomingCustomKeyBindingsRecord,
|
||||
updateCustomKeyBinding as updateCustomKeyBindingRecord,
|
||||
} from '../../domain/customKeyBindings';
|
||||
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
|
||||
import { applyCustomAccentToTerminalTheme, resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE, isDeprecatedPrimaryFontId } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
@@ -254,6 +256,12 @@ export const useSettingsState = () => {
|
||||
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
return !isUpgrade;
|
||||
});
|
||||
const [terminalThemeDarkId, setTerminalThemeDarkId] = useState<string>(
|
||||
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK) || TERMINAL_THEME_AUTO,
|
||||
);
|
||||
const [terminalThemeLightId, setTerminalThemeLightId] = useState<string>(
|
||||
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT) || TERMINAL_THEME_AUTO,
|
||||
);
|
||||
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
return migrateIncomingTerminalFontId(stored) ?? DEFAULT_FONT_FAMILY;
|
||||
@@ -536,6 +544,10 @@ export const useSettingsState = () => {
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
|
||||
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
|
||||
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
|
||||
if (storedTermThemeLight) setTerminalThemeLightId(storedTermThemeLight);
|
||||
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
|
||||
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
|
||||
@@ -669,6 +681,12 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
||||
setTerminalThemeId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME_DARK && typeof value === 'string') {
|
||||
setTerminalThemeDarkId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME_LIGHT && typeof value === 'string') {
|
||||
setTerminalThemeLightId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
|
||||
const next = value === true || value === 'true';
|
||||
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
|
||||
@@ -862,6 +880,15 @@ export const useSettingsState = () => {
|
||||
setTerminalThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync per-mode follow terminal themes from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME_DARK && e.newValue) {
|
||||
const next = e.newValue;
|
||||
setTerminalThemeDarkId((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERM_THEME_LIGHT && e.newValue) {
|
||||
const next = e.newValue;
|
||||
setTerminalThemeLightId((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
// Sync follow-app-theme toggle from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
|
||||
const next = e.newValue === 'true';
|
||||
@@ -1011,6 +1038,18 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
|
||||
}, [followAppTerminalTheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
|
||||
}, [terminalThemeDarkId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
|
||||
}, [terminalThemeLightId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
if (!persistMountedRef.current) return;
|
||||
@@ -1293,25 +1332,32 @@ export const useSettingsState = () => {
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const currentTerminalTheme = useMemo(() => {
|
||||
let baseTheme: TerminalTheme;
|
||||
// When "Follow Application Theme" is enabled, pick the terminal theme
|
||||
// whose background matches the active UI theme preset.
|
||||
// When "Follow Application Theme" is enabled, honor the per-mode override
|
||||
// (or auto-match the active UI theme preset when set to auto).
|
||||
if (followAppTerminalTheme) {
|
||||
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
|
||||
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
|
||||
if (mapped) {
|
||||
const found = TERMINAL_THEMES.find(t => t.id === mapped);
|
||||
if (found) {
|
||||
baseTheme = found;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
const followedId = resolveFollowedTerminalThemeId({
|
||||
resolvedTheme,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
const followed = TERMINAL_THEMES.find(t => t.id === followedId)
|
||||
|| customThemes.find(t => t.id === followedId);
|
||||
if (followed) {
|
||||
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
|
||||
}
|
||||
// Explicit override pointing at a deleted theme: fall through to the
|
||||
// manual theme below.
|
||||
}
|
||||
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
const baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
}, [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
|
||||
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
|
||||
accentMode, customAccent]);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
@@ -1348,6 +1394,10 @@ export const useSettingsState = () => {
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
|
||||
terminalThemeDarkId,
|
||||
setTerminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
setTerminalThemeLightId,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
|
||||
@@ -150,6 +150,16 @@ export const useSftpBackend = () => {
|
||||
return bridge.getHomeDir();
|
||||
}, []);
|
||||
|
||||
const listDrives = useCallback(async () => {
|
||||
return await netcattyBridge.get()?.listDrives?.() ?? [];
|
||||
}, []);
|
||||
|
||||
const openPath = useCallback(async (path: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openPath) throw new Error("openPath unavailable");
|
||||
return bridge.openPath(path);
|
||||
}, []);
|
||||
|
||||
const startStreamTransfer = useCallback(
|
||||
async (
|
||||
options: Parameters<NonNullable<NetcattyBridge["startStreamTransfer"]>>[0],
|
||||
@@ -268,6 +278,8 @@ export const useSftpBackend = () => {
|
||||
mkdirLocal,
|
||||
statLocal,
|
||||
getHomeDir,
|
||||
listDrives,
|
||||
openPath,
|
||||
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
|
||||
@@ -73,6 +73,11 @@ export const useTerminalBackend = () => {
|
||||
bridge?.resizeSession?.(sessionId, cols, rows);
|
||||
}, []);
|
||||
|
||||
const setSessionFlowPaused = useCallback((sessionId: string, paused: boolean) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setSessionFlowPaused?.(sessionId, paused);
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback((sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.closeSession?.(sessionId);
|
||||
@@ -208,6 +213,7 @@ export const useTerminalBackend = () => {
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
@@ -240,6 +246,7 @@ export const useTerminalBackend = () => {
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import { sanitizeGroupConfig } from "../../domain/groupConfig";
|
||||
import { normalizeKnownHosts } from "../../domain/knownHosts";
|
||||
import {
|
||||
ConnectionLog,
|
||||
GroupConfig,
|
||||
@@ -505,11 +506,22 @@ export const useVaultState = () => {
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
// Load known hosts. Records imported from `~/.ssh/known_hosts` and
|
||||
// records saved by older builds may be missing the `fingerprint` /
|
||||
// `keyType` fields the verifier compares against; backfill them now
|
||||
// so the next SSH connect can match without falling into the brittle
|
||||
// re-derivation path that caused the repeated "fingerprint changed"
|
||||
// warnings in #972.
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
if (savedKnownHosts) {
|
||||
const normalized = normalizeKnownHosts(savedKnownHosts);
|
||||
setKnownHosts(normalized);
|
||||
if (normalized !== savedKnownHosts) {
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
@@ -638,7 +650,7 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_KNOWN_HOSTS) {
|
||||
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
|
||||
setKnownHosts(next);
|
||||
setKnownHosts(normalizeKnownHosts(next));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
@@ -135,6 +136,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
commandTimeout: 120,
|
||||
maxIterations: 10,
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
webSearchConfig: webSearch,
|
||||
});
|
||||
});
|
||||
@@ -201,6 +203,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
commandTimeout: 30,
|
||||
maxIterations: 5,
|
||||
agentModelMap: { claude: "claude-test" },
|
||||
agentProviderMap: { catty: "anthropic-main" },
|
||||
webSearchConfig: webSearch,
|
||||
},
|
||||
},
|
||||
@@ -219,9 +222,104 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
|
||||
});
|
||||
|
||||
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
|
||||
// Without this nudge, the apply path writes to localStorage but
|
||||
// `useAIState` (listening for `storage` events) never sees the changes
|
||||
// in the calling window — mounted UI keeps showing pre-sync data.
|
||||
const dispatched: Array<{ type: string; detail: unknown }> = [];
|
||||
const fakeWindow = {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent(event: Event) {
|
||||
dispatched.push({
|
||||
type: event.type,
|
||||
detail: (event as CustomEvent).detail,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
||||
try {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "deepseek-local" }));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ catty: "deepseek-v4-flash" }));
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
providers: [{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true }],
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const events = dispatched.filter((e) => e.type === "netcatty:ai-state-changed");
|
||||
const keys = events.map((e) => (e.detail as { key?: string })?.key);
|
||||
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_PROVIDERS), "providers nudge");
|
||||
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP), "agentProviderMap nudge");
|
||||
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP), "agentModelMap nudge");
|
||||
} finally {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
});
|
||||
|
||||
test("applySyncPayload prunes per-agent bindings that reference providers absent from the synced set", async () => {
|
||||
// Local state has Catty bound to a provider the incoming sync no longer
|
||||
// ships — both the per-agent provider override and the saved model should
|
||||
// be cleared so we don't dispatch a ghost provider id (or its now-orphan
|
||||
// model name) to the wrong endpoint.
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({
|
||||
catty: "deepseek-local",
|
||||
codex: "openai-main",
|
||||
}));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({
|
||||
catty: "deepseek-v4-flash",
|
||||
codex: "gpt-test",
|
||||
}));
|
||||
|
||||
const syncedProviders = [
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true },
|
||||
];
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
providers: syncedProviders,
|
||||
// Intentionally omit agentProviderMap — exercises the reconcile path.
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!),
|
||||
{ codex: "openai-main" },
|
||||
);
|
||||
// Catty's saved model belonged to the now-missing deepseek-local — drop it.
|
||||
// Codex's binding stays, so its saved model stays.
|
||||
assert.deepEqual(
|
||||
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!),
|
||||
{ codex: "gpt-test" },
|
||||
);
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves local externalAgents and ignores legacy payload field", async () => {
|
||||
const localAgents = [
|
||||
{ id: "codex", name: "Codex", command: "/usr/local/bin/codex", enabled: true },
|
||||
|
||||
@@ -18,7 +18,12 @@ import type {
|
||||
Snippet,
|
||||
SSHKey,
|
||||
} from '../domain/models';
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import {
|
||||
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
hasSyncPayloadEntityData,
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import {
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
@@ -26,7 +31,8 @@ import {
|
||||
} from '../domain/customKeyBindings';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -38,6 +44,8 @@ import {
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
@@ -66,7 +74,9 @@ import {
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,19 +104,7 @@ export interface SyncableVaultData {
|
||||
* protecting or syncing.
|
||||
*/
|
||||
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.proxyProfiles?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
@@ -118,24 +116,39 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
* Local-only trust records are intentionally ignored.
|
||||
*/
|
||||
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.proxyProfiles?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizePortForwardingRulesForSync(
|
||||
rules: PortForwardingRule[] | undefined,
|
||||
): PortForwardingRule[] | undefined {
|
||||
if (!rules) return rules;
|
||||
return rules.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getEffectivePortForwardingRulesForSync(
|
||||
rules: PortForwardingRule[] | undefined,
|
||||
): PortForwardingRule[] | undefined {
|
||||
let effectiveRules = rules;
|
||||
if (!effectiveRules || effectiveRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING);
|
||||
if (Array.isArray(stored) && stored.length > 0) {
|
||||
effectiveRules = stored;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizePortForwardingRulesForSync(effectiveRules);
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
interface SyncPayloadImporters {
|
||||
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
|
||||
@@ -152,15 +165,16 @@ interface SyncPayloadImporters {
|
||||
|
||||
/** Terminal settings keys that are safe to sync (platform-agnostic). */
|
||||
const SYNCABLE_TERMINAL_KEYS = [
|
||||
'startupCommandDelayMs',
|
||||
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
|
||||
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'osc52Clipboard', 'showServerStats',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
||||
'serverStatsRefreshInterval', 'rendererType',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
@@ -177,6 +191,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_THEME_DARK,
|
||||
STORAGE_KEY_TERM_THEME_LIGHT,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
@@ -205,6 +221,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
] as const;
|
||||
|
||||
@@ -300,6 +317,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
|
||||
settings.followAppTerminalTheme = followAppTermTheme === 'true';
|
||||
}
|
||||
const termThemeDark = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK);
|
||||
if (termThemeDark) settings.terminalThemeDark = termThemeDark;
|
||||
const termThemeLight = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT);
|
||||
if (termThemeLight) settings.terminalThemeLight = termThemeLight;
|
||||
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (termFont) settings.terminalFontFamily = termFont;
|
||||
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
@@ -396,6 +417,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
|
||||
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
if (agentModelMap) ai.agentModelMap = agentModelMap;
|
||||
const agentProviderMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
|
||||
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
if (Object.keys(ai).length > 0) settings.ai = ai;
|
||||
@@ -423,6 +446,8 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.followAppTerminalTheme != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
|
||||
}
|
||||
if (settings.terminalThemeDark != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, settings.terminalThemeDark);
|
||||
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
||||
|
||||
@@ -513,6 +538,7 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
|
||||
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
|
||||
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
|
||||
if (ai.agentProviderMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, ai.agentProviderMap);
|
||||
if (ai.webSearchConfig !== undefined) {
|
||||
if (ai.webSearchConfig === null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
@@ -523,6 +549,83 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
);
|
||||
}
|
||||
}
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
// points at ids the synced provider set doesn't include — either way
|
||||
// we'd leak overrides bound to ghost providers. Mirrors the same
|
||||
// cleanup `removeProvider` does for explicit user deletes.
|
||||
pruneOrphanPerAgentBindings();
|
||||
// Nudge same-window AI state listeners. localStorage writes only fire
|
||||
// `storage` events in *other* windows; without this nudge the open
|
||||
// chat panel keeps showing pre-sync providers/bindings until reload.
|
||||
notifyAIStateAfterSync(ai);
|
||||
}
|
||||
}
|
||||
|
||||
function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']): void {
|
||||
if (!ai) return;
|
||||
// Every AI storage key that `applySyncableSettings` may have touched
|
||||
// gets a same-window nudge. `useAIState` listens for these and refreshes
|
||||
// the corresponding React state by re-reading localStorage.
|
||||
const touched: Array<string> = [];
|
||||
if (ai.providers != null) touched.push(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (ai.activeProviderId != null) touched.push(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
||||
if (ai.activeModelId != null) touched.push(STORAGE_KEY_AI_ACTIVE_MODEL);
|
||||
if (ai.globalPermissionMode != null) touched.push(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (ai.toolIntegrationMode != null) touched.push(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
|
||||
if (ai.hostPermissions != null) touched.push(STORAGE_KEY_AI_HOST_PERMISSIONS);
|
||||
if (ai.defaultAgentId != null) touched.push(STORAGE_KEY_AI_DEFAULT_AGENT);
|
||||
if (ai.commandBlocklist != null) touched.push(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
|
||||
if (ai.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
|
||||
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
// agentProviderMap is *always* potentially mutated because the reconcile
|
||||
// step may have pruned it even if the payload didn't ship one.
|
||||
touched.push(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
|
||||
// The reconcile may also have pruned saved models alongside provider
|
||||
// bindings, so always nudge the model map too.
|
||||
if (!touched.includes(STORAGE_KEY_AI_AGENT_MODEL_MAP)) {
|
||||
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
}
|
||||
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
for (const key of touched) {
|
||||
emitAIStateChanged(key);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneOrphanPerAgentBindings(): void {
|
||||
const providers = localStorageAdapter.read<Array<{ id?: string }>>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
const validIds = new Set(
|
||||
providers
|
||||
.map((p) => p?.id)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0),
|
||||
);
|
||||
const providerMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
|
||||
const modelMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
|
||||
let providerChanged = false;
|
||||
let modelChanged = false;
|
||||
const nextProviderMap: Record<string, string> = {};
|
||||
const nextModelMap: Record<string, string> = { ...modelMap };
|
||||
for (const agentId of Object.keys(providerMap)) {
|
||||
const providerId = providerMap[agentId];
|
||||
if (providerId && validIds.has(providerId)) {
|
||||
nextProviderMap[agentId] = providerId;
|
||||
} else {
|
||||
providerChanged = true;
|
||||
// Drop the saved model too — that id belonged to the now-missing
|
||||
// provider and isn't trustworthy against any other binding.
|
||||
if (agentId in nextModelMap) {
|
||||
delete nextModelMap[agentId];
|
||||
modelChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (providerChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, nextProviderMap);
|
||||
}
|
||||
if (modelChanged) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, nextModelMap);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,7 +653,7 @@ export function buildSyncPayload(
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules,
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -611,7 +714,7 @@ function applyPayload(
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
@@ -145,6 +146,8 @@ interface AIChatSidePanelProps {
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
agentModelMap: Record<string, string>;
|
||||
setAgentModel: (agentId: string, modelId: string) => void;
|
||||
agentProviderMap: Record<string, string>;
|
||||
setAgentProvider: (agentId: string, providerId: string) => void;
|
||||
|
||||
// Safety
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
@@ -225,6 +228,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setExternalAgents,
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
@@ -561,8 +566,67 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
[providers, activeProviderId],
|
||||
);
|
||||
|
||||
const providerDisplayName = activeProvider?.name ?? '';
|
||||
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
||||
// Catty Agent honors a per-agent provider/model override from
|
||||
// `agentProviderMap` / `agentModelMap`, falling back to the global active
|
||||
// selection. External ACP agents (Claude/Codex/Copilot) keep their
|
||||
// existing provider plumbing — the user picks them inside the ACP CLI
|
||||
// itself, so a per-agent provider override doesn't apply.
|
||||
const cattyAgentProvider = useMemo(() => {
|
||||
const overrideId = agentProviderMap['catty'];
|
||||
if (overrideId) {
|
||||
const p = providers.find((cfg) => cfg.id === overrideId);
|
||||
if (p) return p;
|
||||
// Override exists but points to a deleted provider — fall through
|
||||
// to the global active selection.
|
||||
}
|
||||
return activeProvider;
|
||||
}, [agentProviderMap, providers, activeProvider]);
|
||||
|
||||
const cattyAgentModelId = useMemo(() => {
|
||||
// Whitespace-only model ids are treated as "no model" everywhere
|
||||
// (picker, send guard, SDK) — normalize at the resolution boundary
|
||||
// so a stored " " never slips through downstream checks.
|
||||
const trim = (s: string | undefined | null): string => (s ?? '').trim();
|
||||
const overrideId = agentProviderMap['catty'];
|
||||
const overrideProvider = overrideId
|
||||
? providers.find((cfg) => cfg.id === overrideId)
|
||||
: undefined;
|
||||
if (overrideProvider) {
|
||||
// Override intact — prefer the per-agent saved model, then the
|
||||
// override provider's defaultModel. Never reach for the global
|
||||
// `activeModelId` here: that id belongs to whichever provider
|
||||
// was globally active, not the one Catty is bound to now.
|
||||
return trim(agentModelMap['catty']) || trim(overrideProvider.defaultModel);
|
||||
}
|
||||
// No override, OR a stale override (the bound provider was deleted):
|
||||
// in either case the saved model id is no longer trustworthy as a
|
||||
// Catty pick, so consult the global active selection instead.
|
||||
return trim(cattyAgentProvider?.defaultModel) || trim(activeModelId);
|
||||
}, [agentModelMap, agentProviderMap, providers, cattyAgentProvider, activeModelId]);
|
||||
|
||||
const effectiveActiveProvider = currentAgentId === 'catty' ? cattyAgentProvider : activeProvider;
|
||||
const effectiveActiveModelId = currentAgentId === 'catty' ? cattyAgentModelId : activeModelId;
|
||||
|
||||
// Catty Agent surfaces its provider picker in the chat input. The list
|
||||
// mirrors what Settings → AI → Providers shows — every configured
|
||||
// provider, regardless of the per-provider `enabled` toggle, so the
|
||||
// user can swap between everything they've set up without first going
|
||||
// back into Settings to flip a switch.
|
||||
const cattyConfiguredProviders = useMemo(
|
||||
() => (currentAgentId === 'catty' ? providers : []),
|
||||
[currentAgentId, providers],
|
||||
);
|
||||
|
||||
const handleAgentProviderModelSelect = useCallback(
|
||||
(providerId: string, modelId: string) => {
|
||||
setAgentProvider(currentAgentId, providerId);
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
},
|
||||
[currentAgentId, setAgentProvider, setAgentModel],
|
||||
);
|
||||
|
||||
const providerDisplayName = effectiveActiveProvider?.name ?? '';
|
||||
const modelDisplayName = effectiveActiveModelId || effectiveActiveProvider?.defaultModel || '';
|
||||
|
||||
// Agent model presets for the current external agent
|
||||
const currentAgentConfig = useMemo(
|
||||
@@ -636,6 +700,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
currentAgentConfig.env,
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
// If the probe came back empty, drop any stale cached catalog for this
|
||||
@@ -857,8 +922,14 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
|
||||
// Catty Agent picks up the per-agent provider/model override. External
|
||||
// ACP agents continue to ride the global selection (they wire their
|
||||
// own provider through the CLI).
|
||||
const sendActiveProvider = isExternalAgent ? activeProvider : effectiveActiveProvider;
|
||||
const sendActiveModelId = isExternalAgent ? activeModelId : effectiveActiveModelId;
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
if (!isExternalAgent && !sendActiveProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
@@ -868,6 +939,23 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Catty needs a concrete model id — the SDK would otherwise dispatch
|
||||
// an empty string and surface a vague backend error. The chat-input
|
||||
// chip already disables provider rows with no defaultModel, but a
|
||||
// stale binding (e.g. user emptied the provider's defaultModel after
|
||||
// selecting it) can still land here. Trim before checking so
|
||||
// whitespace-only ids (which the picker also treats as empty) don't
|
||||
// sneak past either.
|
||||
if (!isExternalAgent && !sendActiveModelId.trim()) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
@@ -885,8 +973,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
: (sendActiveModelId || sendActiveProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : sendActiveProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -926,8 +1014,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
activeProvider: sendActiveProvider,
|
||||
activeModelId: sendActiveModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
@@ -946,7 +1034,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope,
|
||||
@@ -1035,24 +1123,32 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
title="Session history"
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
title="New chat"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1117,6 +1213,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
@@ -1199,13 +1305,17 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { toast } from './ui/toast';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
// ============================================================================
|
||||
// Provider Icons
|
||||
@@ -377,12 +378,14 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<p
|
||||
className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help"
|
||||
title={error}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help">
|
||||
{error}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{error}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
|
||||
@@ -1904,9 +1907,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
{entry.error && (
|
||||
<span className="text-xs text-red-500 truncate max-w-24" title={entry.error}>
|
||||
{t('cloudSync.history.error')}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs text-red-500 truncate max-w-24 cursor-default">
|
||||
{t('cloudSync.history.error')}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{entry.error}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
interface ConnectionLogsManagerProps {
|
||||
logs: ConnectionLog[];
|
||||
@@ -108,31 +109,39 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
|
||||
{/* Saved column */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSaved(log.id);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
log.saved
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
)}
|
||||
title={log.saved ? t("logs.action.unsave") : t("logs.action.save")}
|
||||
>
|
||||
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(log.id);
|
||||
}}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title={t("logs.action.delete")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSaved(log.id);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
log.saved
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{log.saved ? t("logs.action.unsave") : t("logs.action.save")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(log.id);
|
||||
}}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("logs.action.delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ interface FileOpenerDialogProps {
|
||||
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileKey,
|
||||
@@ -34,6 +36,8 @@ import {
|
||||
SSHKey,
|
||||
} from "../types";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import {
|
||||
ChainPanel,
|
||||
EnvVarsPanel,
|
||||
@@ -50,10 +54,13 @@ import { Card } from "./ui/card";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
|
||||
|
||||
@@ -110,7 +117,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
@@ -126,6 +133,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showTelnetPassword, setShowTelnetPassword] = useState(false);
|
||||
const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false);
|
||||
const [addProtocolOpen, setAddProtocolOpen] = useState(false);
|
||||
|
||||
// Credential selection state
|
||||
@@ -170,6 +178,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
delete next.agentForwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.legacyAlgorithms;
|
||||
delete next.skipEcdsaHostKey;
|
||||
delete next.algorithms;
|
||||
delete next.backspaceBehavior;
|
||||
delete next.proxyProfileId;
|
||||
delete next.proxyConfig;
|
||||
@@ -311,6 +321,36 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
if (!parentGroup || groupConfigs.length === 0) return terminalThemeId;
|
||||
return resolveGroupTerminalThemeId(resolveGroupDefaults(parentGroup, groupConfigs), terminalThemeId);
|
||||
}, [groupConfigs, parentGroup, terminalThemeId]);
|
||||
|
||||
// Effective `legacyAlgorithms` for this group, considering inheritance
|
||||
// from the parent chain. Used by the algorithm-overrides editor so the
|
||||
// seed reflects what hosts in this group would actually advertise — if
|
||||
// the parent group already turned legacy mode on, the editor should
|
||||
// include legacy algorithms in its default list even when this group
|
||||
// itself hasn't set the flag.
|
||||
const inheritedLegacyAlgorithms = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).legacyAlgorithms;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
|
||||
// Same idea for the algorithm-override lists themselves: surface what
|
||||
// this group would inherit from its parent so the editor can warn that
|
||||
// a local Reset falls back to the parent's lists, not NetCatty's
|
||||
// defaults.
|
||||
const inheritedAlgorithmOverrides = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return undefined;
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).algorithms;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
|
||||
// And for the per-flag toggles below — if the parent already turned
|
||||
// a flag on, the runtime applies it to hosts in this group via
|
||||
// `applyGroupDefaults`, so the local toggle must reflect that. Without
|
||||
// this, a child group would show the flag as off while connections
|
||||
// still negotiated with it.
|
||||
const inheritedSkipEcdsaHostKey = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const effectiveThemeId = form.themeOverride === false
|
||||
? inheritedThemeId
|
||||
: (form.theme || inheritedThemeId);
|
||||
@@ -359,6 +399,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
|
||||
...(form.skipEcdsaHostKey !== undefined && { skipEcdsaHostKey: form.skipEcdsaHostKey }),
|
||||
...(form.algorithms !== undefined && { algorithms: form.algorithms }),
|
||||
...(form.backspaceBehavior !== undefined && { backspaceBehavior: form.backspaceBehavior }),
|
||||
...(form.proxyProfileId !== undefined && { proxyProfileId: form.proxyProfileId }),
|
||||
...(normalizedProxyConfig !== undefined && { proxyConfig: normalizedProxyConfig }),
|
||||
@@ -814,29 +856,33 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title={t("hostDetails.credential.browseKeyFile")}
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -855,33 +901,69 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
|
||||
{/* Startup Command */}
|
||||
<Input
|
||||
{/* Startup Command — Textarea so multi-line sequences are typeable
|
||||
here just like on the per-host details panel (#1083 follow-up). */}
|
||||
<Textarea
|
||||
placeholder={t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
{/* Display the *effective* value (this group's field falling
|
||||
back to the resolved parent default). Same rationale as
|
||||
in HostDetailsPanel — without the fallback, a child group
|
||||
that inherits a flag from a parent would show "off" in
|
||||
the UI while connections still applied it. */}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
enabled={!!form.legacyAlgorithms}
|
||||
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
|
||||
enabled={!!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms)}
|
||||
onToggle={() => update(
|
||||
"legacyAlgorithms",
|
||||
!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms),
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Backspace behavior */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.backspaceBehavior ?? ""}
|
||||
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
|
||||
>
|
||||
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
|
||||
<option value="ctrl-h">^H (0x08)</option>
|
||||
</select>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.skipEcdsaHostKey")}
|
||||
enabled={!!(form.skipEcdsaHostKey ?? inheritedSkipEcdsaHostKey)}
|
||||
onToggle={() => update(
|
||||
"skipEcdsaHostKey",
|
||||
!(form.skipEcdsaHostKey ?? inheritedSkipEcdsaHostKey),
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.skipEcdsaHostKey.desc")}
|
||||
</p>
|
||||
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("hostDetails.algorithms.advanced")}
|
||||
{form.algorithms && Object.keys(form.algorithms).length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
({t("hostDetails.algorithms.customized")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showAlgorithmOverrides
|
||||
? <ChevronUp size={14} className="text-muted-foreground" />
|
||||
: <ChevronDown size={14} className="text-muted-foreground" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<AlgorithmOverridesPanel
|
||||
value={form.algorithms}
|
||||
legacyEnabled={!!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms)}
|
||||
inheritedFromGroup={inheritedAlgorithmOverrides}
|
||||
onChange={(next) => update("algorithms", next)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Proxy */}
|
||||
<button
|
||||
@@ -895,14 +977,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{(form.proxyConfig?.host || form.proxyProfileId) && (
|
||||
<div title={proxySummaryLabel} className="min-w-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-[160px] truncate text-xs"
|
||||
>
|
||||
{proxySummaryLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 cursor-default">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-[160px] truncate text-xs"
|
||||
>
|
||||
{proxySummaryLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{proxySummaryLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
@@ -962,6 +1049,25 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Backspace behavior — terminal input mapping, lives at the
|
||||
bottom of the SSH section so it doesn't get visually
|
||||
grouped with the algorithm controls above. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const hostWithMissingProxyProfile: Host = {
|
||||
id: "host-1",
|
||||
@@ -26,20 +27,24 @@ const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -111,29 +116,33 @@ test("HostDetailsPanel displays inherited telnet port before falling back to 23"
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: undefined,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: undefined,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -145,29 +154,33 @@ test("HostDetailsPanel uses group telnet port instead of ssh port for optional t
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "ssh",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: 2222,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "ssh",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: 2222,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -181,35 +194,39 @@ test("HostDetailsPanel displays inherited telnet credentials", () => {
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
username: "ssh-user",
|
||||
password: "ssh-password",
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPassword: "group-telnet-password",
|
||||
}],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
username: "ssh-user",
|
||||
password: "ssh-password",
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPassword: "group-telnet-password",
|
||||
}],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderLock,
|
||||
@@ -55,6 +56,8 @@ import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyP
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
@@ -213,6 +216,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false);
|
||||
|
||||
// Local key file path input state
|
||||
const [newKeyFilePath, setNewKeyFilePath] = useState("");
|
||||
@@ -938,15 +942,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{selectedIdentity.label}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
title={t("common.clear")}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.clear")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : form.identityId ? (
|
||||
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
|
||||
@@ -956,15 +964,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{t("hostDetails.identity.missing")}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
title={t("common.clear")}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.clear")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
@@ -1019,29 +1031,33 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}}
|
||||
className="h-10 pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
setIdentitySuggestionsOpen((prev) => {
|
||||
if (prev) return false;
|
||||
const q = (form.username || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
return matches.length > 0;
|
||||
});
|
||||
}}
|
||||
title={t("hostDetails.identity.suggestions")}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
setIdentitySuggestionsOpen((prev) => {
|
||||
if (prev) return false;
|
||||
const q = (form.username || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
return matches.length > 0;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.identity.suggestions")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -1123,14 +1139,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onChange={(e) => update("password", e.target.value)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1153,9 +1173,14 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
|
||||
{keyPath}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs w-0 flex-1 truncate font-mono cursor-default">
|
||||
{keyPath}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{keyPath}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1366,26 +1391,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title={t("hostDetails.credential.browseKeyFile")}
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
addLocalKeyFilePath(filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
addLocalKeyFilePath(filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1772,21 +1801,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
{/* SSH Algorithms */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.legacyAlgorithms")}</p>
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.sshAlgorithms")}</p>
|
||||
</div>
|
||||
{/* Display the *effective* value of these toggles (host field
|
||||
falling back to the resolved group default). Without the
|
||||
fallback a host that inherits the flag from its group would
|
||||
show "off" while the runtime applied it anyway, and the
|
||||
toggle's onToggle handler would compute the wrong "next"
|
||||
value from the raw host field. */}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
enabled={!!form.legacyAlgorithms}
|
||||
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
|
||||
enabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
|
||||
onToggle={() => update(
|
||||
"legacyAlgorithms",
|
||||
!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms),
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.legacyAlgorithms.desc")}
|
||||
</p>
|
||||
{form.legacyAlgorithms && (
|
||||
{(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms) && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
@@ -1794,16 +1832,75 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<ToggleRow
|
||||
label={t("hostDetails.skipEcdsaHostKey")}
|
||||
enabled={!!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey)}
|
||||
onToggle={() => update(
|
||||
"skipEcdsaHostKey",
|
||||
!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey),
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.skipEcdsaHostKey.desc")}
|
||||
</p>
|
||||
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("hostDetails.algorithms.advanced")}
|
||||
{form.algorithms && Object.keys(form.algorithms).length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
({t("hostDetails.algorithms.customized")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showAlgorithmOverrides
|
||||
? <ChevronUp size={14} className="text-muted-foreground" />
|
||||
: <ChevronDown size={14} className="text-muted-foreground" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<AlgorithmOverridesPanel
|
||||
value={form.algorithms}
|
||||
/* Use the effective legacy flag (host value falling back to
|
||||
the currently selected group's default) so the seed
|
||||
reflects what the host would actually advertise. We
|
||||
read from `effectiveGroupDefaults` (re-resolved on
|
||||
every form.group change), not the `groupDefaults` prop
|
||||
— otherwise switching the host into a different group
|
||||
without saving first would seed from the original
|
||||
group's flag and silently mis-populate the override. */
|
||||
legacyEnabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
|
||||
inheritedFromGroup={effectiveGroupDefaults?.algorithms}
|
||||
onChange={(next) => update("algorithms", next)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
|
||||
{/* Terminal Behavior — input/output key mappings (backspace, etc.) */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalSquare size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.terminalBehavior")}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.backspaceBehavior ?? ""}
|
||||
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
|
||||
<option value="ctrl-h">^H (0x08)</option>
|
||||
</select>
|
||||
<SelectTrigger className="h-8 w-auto text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig";
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
@@ -54,6 +56,7 @@ import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
// Import utilities and components from keychain module
|
||||
import {
|
||||
@@ -74,6 +77,13 @@ interface KeychainManagerProps {
|
||||
hosts?: Host[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
customGroups?: string[];
|
||||
/**
|
||||
* Group default configurations. Needed by the "export public key to
|
||||
* host" flow so per-host SSH algorithm settings (legacy / skipEcdsa /
|
||||
* overrides) that the host inherits from its group are honored when
|
||||
* the export opens its one-off SSH connection.
|
||||
*/
|
||||
groupConfigs?: GroupConfig[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
onUpdate: (key: SSHKey) => void;
|
||||
@@ -91,6 +101,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
hosts = [],
|
||||
proxyProfiles = [],
|
||||
customGroups = [],
|
||||
groupConfigs = [],
|
||||
managedSources = [],
|
||||
onSave,
|
||||
onUpdate,
|
||||
@@ -1068,11 +1079,22 @@ echo $3 >> "$FILE"`);
|
||||
// Execute the script directly - SSH exec handles multiline commands
|
||||
const command = scriptWithVars;
|
||||
|
||||
// Resolve the effective host (applying group
|
||||
// defaults), so algorithm settings inherited from
|
||||
// the group reach the bridge — the host object on
|
||||
// its own only carries explicitly set fields.
|
||||
const effectiveExportHost = exportHost.group
|
||||
? applyGroupDefaults(
|
||||
exportHost,
|
||||
resolveGroupDefaults(exportHost.group, groupConfigs),
|
||||
)
|
||||
: applyGroupDefaults(exportHost, {});
|
||||
|
||||
// Execute via SSH
|
||||
const result = await execCommand({
|
||||
hostname: exportHost.hostname,
|
||||
hostname: effectiveExportHost.hostname,
|
||||
username: exportAuth.username,
|
||||
port: exportHost.port || 22,
|
||||
port: effectiveExportHost.port || 22,
|
||||
password: exportPassword,
|
||||
privateKey: exportKeyAuth.privateKey,
|
||||
certificate: exportAuth.key?.certificate,
|
||||
@@ -1081,10 +1103,17 @@ echo $3 >> "$FILE"`);
|
||||
keySource: exportAuth.key?.source,
|
||||
passphrase: exportKeyAuth.passphrase,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
// Carry the effective host's algorithm settings
|
||||
// (host value falling back to its group default)
|
||||
// so the one-off SSH exec honors them just like
|
||||
// the interactive terminal does.
|
||||
legacyAlgorithms: effectiveExportHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: effectiveExportHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: effectiveExportHost.algorithms,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${exportHost.id}:${panel.key.id}`,
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
// Check result - code 0, null, or undefined with no stderr is success
|
||||
@@ -1168,9 +1197,14 @@ echo $3 >> "$FILE"`);
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs font-mono truncate" title={draftKey.filePath}>
|
||||
{draftKey.filePath}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs font-mono truncate cursor-default">
|
||||
{draftKey.filePath}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{draftKey.filePath}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import React, {
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
|
||||
import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode";
|
||||
import { fingerprintFromPublicKey } from "../domain/knownHosts";
|
||||
import { STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -37,6 +38,7 @@ import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
@@ -79,12 +81,20 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
|
||||
hostname = "(hashed)";
|
||||
}
|
||||
|
||||
const fullPublicKey = `${keyType} ${publicKey}`;
|
||||
// Compute the fingerprint up front so the SSH host verifier can match
|
||||
// against this record directly instead of re-deriving on every connect —
|
||||
// the re-derivation path is where the false "fingerprint changed"
|
||||
// warnings in #972 originated.
|
||||
const fingerprint = fingerprintFromPublicKey(fullPublicKey);
|
||||
|
||||
parsed.push({
|
||||
id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
hostname,
|
||||
port,
|
||||
keyType,
|
||||
publicKey: `${keyType} ${publicKey}`,
|
||||
publicKey: fullPublicKey,
|
||||
fingerprint: fingerprint || undefined,
|
||||
discoveredAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
@@ -122,27 +132,35 @@ const HostItem = React.memo<HostItemProps>(
|
||||
{/* Quick action buttons on hover */}
|
||||
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!converted && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-primary/20 text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
title={t("action.convertToHost")}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-primary/20 text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-destructive/20 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(knownHost.id);
|
||||
}}
|
||||
title={t("action.remove")}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-destructive/20 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(knownHost.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.remove")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
|
||||
@@ -193,18 +211,22 @@ const HostItem = React.memo<HostItemProps>(
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!converted && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
title={t("action.convertToHost")}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -277,7 +277,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
className="gap-1.5 h-8 px-2"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
title={t("logView.export")}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="text-xs">{t("logView.export")}</span>
|
||||
@@ -290,7 +289,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 px-2"
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
title={t("logView.customizeAppearance")}
|
||||
>
|
||||
<Palette size={14} />
|
||||
<span className="text-xs">{t("logView.appearance")}</span>
|
||||
|
||||
@@ -298,7 +298,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onClose();
|
||||
}}
|
||||
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
||||
title="New Workspace"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
|
||||
@@ -249,15 +249,19 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSnippet}
|
||||
title={t('snippets.action.newSnippet')}
|
||||
aria-label={t('snippets.action.newSnippet')}
|
||||
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSnippet}
|
||||
aria-label={t('snippets.action.newSnippet')}
|
||||
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('snippets.action.newSnippet')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
|
||||
interface SerialPort {
|
||||
@@ -262,35 +263,41 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<select
|
||||
id="data-bits"
|
||||
value={dataBits}
|
||||
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<select
|
||||
id="stop-bits"
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
@@ -302,35 +309,41 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<select
|
||||
id="parity"
|
||||
<Select
|
||||
value={parity}
|
||||
onChange={(e) => setParity(e.target.value as SerialParity)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<select
|
||||
id="flow-control"
|
||||
<Select
|
||||
value={flowControl}
|
||||
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import {
|
||||
AsidePanel,
|
||||
@@ -291,35 +292,41 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<select
|
||||
id="data-bits"
|
||||
value={dataBits}
|
||||
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<select
|
||||
id="stop-bits"
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
@@ -331,35 +338,41 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<select
|
||||
id="parity"
|
||||
<Select
|
||||
value={parity}
|
||||
onChange={(e) => setParity(e.target.value as SerialParity)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<select
|
||||
id="flow-control"
|
||||
<Select
|
||||
value={flowControl}
|
||||
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { useAIState } from "../application/state/useAIState";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
|
||||
import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
|
||||
@@ -20,6 +21,7 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
@@ -49,6 +51,11 @@ type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const settingsTabTriggerClassName =
|
||||
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
|
||||
const settingsTabIconClassName = "shrink-0";
|
||||
const settingsTabLabelClassName = "min-w-0 truncate";
|
||||
|
||||
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
@@ -58,6 +65,12 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={settings.terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
|
||||
terminalThemeLightId={settings.terminalThemeLightId}
|
||||
setTerminalThemeLightId={settings.setTerminalThemeLightId}
|
||||
lightUiThemeId={settings.lightUiThemeId}
|
||||
darkUiThemeId={settings.darkUiThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
@@ -127,13 +140,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
|
||||
// Strip transient runtime fields before passing to sync
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() =>
|
||||
portForwardingRules.map((rule) => ({
|
||||
...rule,
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
})),
|
||||
() => sanitizePortForwardingRulesForSync(portForwardingRules) ?? [],
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
@@ -187,13 +194,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<h1 className="text-lg font-semibold">{t("settings.title")}</h1>
|
||||
{!isMac && (
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.close")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,51 +219,59 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
<TabsList className="flex flex-col h-auto bg-transparent gap-1 p-0 justify-start">
|
||||
<TabsTrigger
|
||||
value="application"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<AppWindow size={14} /> {t("settings.tab.application")}
|
||||
<AppWindow size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.application")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="appearance"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<Palette size={14} /> {t("settings.tab.appearance")}
|
||||
<Palette size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.appearance")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="terminal"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<TerminalSquare size={14} /> {t("settings.tab.terminal")}
|
||||
<TerminalSquare size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.terminal")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="shortcuts"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<Keyboard size={14} /> {t("settings.tab.shortcuts")}
|
||||
<Keyboard size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.shortcuts")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="file-associations"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
|
||||
<FileType size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.sftpFileAssociations")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="ai"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<Sparkles size={14} /> AI
|
||||
<Sparkles size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>AI</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sync"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<Cloud size={14} /> {t("settings.tab.syncCloud")}
|
||||
<Cloud size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.syncCloud")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="system"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<HardDrive size={14} /> {t("settings.tab.system")}
|
||||
<HardDrive size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.system")}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -19,13 +19,14 @@ import { editorTabStore } from "../application/state/editorTabStore";
|
||||
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { getParentPath } from "../application/state/sftp/utils";
|
||||
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
|
||||
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
|
||||
import { logger } from "../lib/logger";
|
||||
import type { DropEntry } from "../lib/sftpFileUtils";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import type { TransferTask } from "../types";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
|
||||
import { SftpPaneView } from "./sftp/SftpPaneView";
|
||||
@@ -133,6 +134,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
openPath,
|
||||
} = useSftpBackend();
|
||||
|
||||
const sftpRef = useRef(sftp);
|
||||
@@ -296,6 +299,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -573,18 +577,35 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const handleRevealTransferTarget = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
if (!isConcreteTransferTargetPath(task)) return;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
|
||||
|
||||
if (task.targetConnectionId === "local") {
|
||||
try {
|
||||
const result = await openPath(revealPath);
|
||||
if (result.success) return;
|
||||
} catch {
|
||||
// Show the localized error below.
|
||||
}
|
||||
toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connection || connection.isLocal) return;
|
||||
|
||||
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
|
||||
await sftpRef.current.navigateTo("left", revealPath, { force: true });
|
||||
},
|
||||
[],
|
||||
[openPath, t],
|
||||
);
|
||||
|
||||
const canRevealTransferTarget = useCallback(
|
||||
(task: TransferTask) => {
|
||||
if (task.status !== "completed") return false;
|
||||
if (!isConcreteTransferTargetPath(task)) return false;
|
||||
if (task.targetConnectionId === "local") {
|
||||
return true;
|
||||
}
|
||||
if (task.direction !== "upload" && task.direction !== "remote-to-remote") return false;
|
||||
|
||||
const connection = sftp.leftPane.connection;
|
||||
@@ -605,6 +626,24 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
[sftp.leftPane.connection],
|
||||
);
|
||||
|
||||
const canCopyTransferTargetPath = useCallback(
|
||||
(task: TransferTask) => task.status === "completed" && isConcreteTransferTargetPath(task),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopyTransferTargetPath = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
if (!isConcreteTransferTargetPath(task)) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(task.targetPath);
|
||||
toast.success(t("sftp.transfers.copyTargetPathSuccess"), "SFTP");
|
||||
} catch {
|
||||
toast.error(t("sftp.transfers.copyTargetPathError"), "SFTP");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// When the auto-connect effect defers a switch (active transfers or open
|
||||
// editor), the panel still operates on the current connection, not
|
||||
// activeHost. Use the connected host for the header so the label matches
|
||||
@@ -651,18 +690,22 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
size="sm"
|
||||
className="h-5 w-5 rounded-sm shrink-0"
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
|
||||
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{displayHost.label}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground">·</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate cursor-default">
|
||||
<span className="font-medium">
|
||||
{displayHost.label}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground">·</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -699,6 +742,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
allTransfers={sftp.transfers}
|
||||
canRevealTransferTarget={canRevealTransferTarget}
|
||||
onRevealTransferTarget={handleRevealTransferTarget}
|
||||
canCopyTransferTargetPath={canCopyTransferTargetPath}
|
||||
onCopyTransferTargetPath={handleCopyTransferTargetPath}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -708,6 +753,10 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
showTransferQueue={false}
|
||||
canRevealTransferTarget={canRevealTransferTarget}
|
||||
onRevealTransferTarget={handleRevealTransferTarget}
|
||||
canCopyTransferTargetPath={canCopyTransferTargetPath}
|
||||
onCopyTransferTargetPath={handleCopyTransferTargetPath}
|
||||
showHostPickerLeft={showHostPickerLeft}
|
||||
showHostPickerRight={showHostPickerRight}
|
||||
hostSearchLeft={hostSearchLeft}
|
||||
|
||||
@@ -136,3 +136,31 @@ test("keeps reveal target and child toggle as separate buttons", () => {
|
||||
assert.match(markup, /aria-expanded="false"/);
|
||||
assert.match(markup, /aria-controls="children-transfer-1"/);
|
||||
});
|
||||
|
||||
test("renders explicit target actions for completed local downloads", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
id: "download-1",
|
||||
fileName: "report.pdf",
|
||||
sourcePath: "/remote/report.pdf",
|
||||
targetPath: "/Users/alice/Downloads/report.pdf",
|
||||
targetConnectionId: "local",
|
||||
direction: "download",
|
||||
status: "completed",
|
||||
error: undefined,
|
||||
transferredBytes: 1024,
|
||||
},
|
||||
{
|
||||
canRevealTarget: true,
|
||||
onRevealTarget: () => {},
|
||||
canCopyTargetPath: true,
|
||||
onCopyTargetPath: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-label="Open target folder: report\.pdf"/);
|
||||
assert.match(markup, /aria-label="Copy target path: report\.pdf"/);
|
||||
assert.match(markup, /lucide-folder-open/);
|
||||
assert.match(markup, /lucide-clipboard-copy/);
|
||||
});
|
||||
|
||||
@@ -19,12 +19,13 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
|
||||
import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import { Host, Identity, ProxyProfile, SSHKey } from "../types";
|
||||
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
@@ -136,6 +137,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
openPath,
|
||||
} = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
@@ -262,6 +265,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
const visibleTransfers = useMemo(
|
||||
@@ -269,6 +273,75 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
[sftp.transfers],
|
||||
);
|
||||
|
||||
const getTransferTargetDirectory = useCallback(
|
||||
(task: TransferTask) => (task.isDirectory ? task.targetPath : getParentPath(task.targetPath)),
|
||||
[],
|
||||
);
|
||||
|
||||
const findRemoteTransferTargetTab = useCallback((task: TransferTask) => {
|
||||
const state = sftpRef.current;
|
||||
for (const side of ["left", "right"] as const) {
|
||||
const tabs = side === "left" ? state.leftTabs.tabs : state.rightTabs.tabs;
|
||||
const pane = tabs.find((tab) => tab.connection?.id === task.targetConnectionId);
|
||||
if (pane?.connection && !pane.connection.isLocal) {
|
||||
return { side, tabId: pane.id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const canRevealTransferTarget = useCallback(
|
||||
(task: TransferTask) => {
|
||||
if (task.status !== "completed") return false;
|
||||
if (!isConcreteTransferTargetPath(task)) return false;
|
||||
if (task.targetConnectionId === "local") {
|
||||
return true;
|
||||
}
|
||||
return !!findRemoteTransferTargetTab(task);
|
||||
},
|
||||
[findRemoteTransferTargetTab],
|
||||
);
|
||||
|
||||
const handleRevealTransferTarget = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
if (!isConcreteTransferTargetPath(task)) return;
|
||||
const targetDirectory = getTransferTargetDirectory(task);
|
||||
if (task.targetConnectionId === "local") {
|
||||
try {
|
||||
const result = await openPath(targetDirectory);
|
||||
if (result.success) return;
|
||||
} catch {
|
||||
// Show the localized error below.
|
||||
}
|
||||
toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTab = findRemoteTransferTargetTab(task);
|
||||
if (!targetTab) return;
|
||||
await sftpRef.current.navigateTo(targetTab.side, targetDirectory, { force: true, tabId: targetTab.tabId });
|
||||
},
|
||||
[findRemoteTransferTargetTab, getTransferTargetDirectory, openPath, t],
|
||||
);
|
||||
|
||||
const canCopyTransferTargetPath = useCallback(
|
||||
(task: TransferTask) => task.status === "completed" && isConcreteTransferTargetPath(task),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopyTransferTargetPath = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
if (!isConcreteTransferTargetPath(task)) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(task.targetPath);
|
||||
toast.success(t("sftp.transfers.copyTargetPathSuccess"), "SFTP");
|
||||
} catch {
|
||||
toast.error(t("sftp.transfers.copyTargetPathError"), "SFTP");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const containerStyle: React.CSSProperties = isActive
|
||||
? {}
|
||||
: {
|
||||
@@ -473,6 +546,10 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts={effectiveHosts}
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
canRevealTransferTarget={canRevealTransferTarget}
|
||||
onRevealTransferTarget={handleRevealTransferTarget}
|
||||
canCopyTransferTargetPath={canCopyTransferTargetPath}
|
||||
onCopyTransferTargetPath={handleCopyTransferTargetPath}
|
||||
showHostPickerLeft={showHostPickerLeft}
|
||||
showHostPickerRight={showHostPickerRight}
|
||||
hostSearchLeft={hostSearchLeft}
|
||||
|
||||
@@ -745,21 +745,25 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
actions={
|
||||
<>
|
||||
{editingSnippet.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const id = editingSnippet.id;
|
||||
if (!id) return;
|
||||
onDelete(id);
|
||||
handleClosePanel();
|
||||
}}
|
||||
aria-label={t('common.delete')}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const id = editingSnippet.id;
|
||||
if (!id) return;
|
||||
onDelete(id);
|
||||
handleClosePanel();
|
||||
}}
|
||||
aria-label={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -839,18 +843,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
|
||||
{editingSnippet.shortkey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
title={t('snippets.shortkey.clear')}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('snippets.shortkey.clear')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -1269,7 +1277,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
value={newPackageName}
|
||||
onChange={(e) => setNewPackageName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
|
||||
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from './ui/popover';
|
||||
import { toast } from './ui/toast';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
// ============================================================================
|
||||
// Provider Icons
|
||||
@@ -169,26 +170,30 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
|
||||
className
|
||||
)}
|
||||
title={t('sync.cloudSync')}
|
||||
>
|
||||
{getButtonIcon()}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{getButtonIcon()}
|
||||
|
||||
{/* Status indicator dot */}
|
||||
<StatusIndicator
|
||||
status={overallStatus}
|
||||
size="sm"
|
||||
className="absolute top-0.5 right-0.5 ring-2 ring-background"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* Status indicator dot */}
|
||||
<StatusIndicator
|
||||
status={overallStatus}
|
||||
size="sm"
|
||||
className="absolute top-0.5 right-0.5 ring-2 ring-background"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sync.cloudSync')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
key={syncStateKey}
|
||||
@@ -222,16 +227,20 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
</div>
|
||||
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title={t('sync.settings')}
|
||||
>
|
||||
<Settings size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<Settings size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sync.settings')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { detectLocalOs } from "../lib/localShell";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
import {
|
||||
@@ -29,12 +29,13 @@ import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { toast } from "./ui/toast";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { composeFontFamilyStack, type SupportedPlatform } from "../infrastructure/config/cjkFonts";
|
||||
@@ -48,11 +49,20 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
|
||||
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
||||
import {
|
||||
createPromptLineBreakState,
|
||||
type PromptLineBreakState,
|
||||
} from "./terminal/runtime/promptLineBreak";
|
||||
import { recordTerminalCommandExecution } from "./terminal/runtime/terminalCommandExecution";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
@@ -61,7 +71,10 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
|
||||
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
||||
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
||||
|
||||
const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
|
||||
|
||||
/**
|
||||
* Extract unique root paths from drop entries for local terminal path insertion.
|
||||
@@ -162,6 +175,7 @@ interface TerminalProps {
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
sourceSessionId?: string,
|
||||
) => void;
|
||||
onTerminalCwdChange?: (sessionId: string, cwd: string | null) => void;
|
||||
onOpenScripts?: () => void;
|
||||
onOpenTheme?: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
@@ -252,6 +266,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onOpenSftp,
|
||||
onTerminalCwdChange,
|
||||
onOpenScripts,
|
||||
onOpenTheme,
|
||||
isBroadcastEnabled,
|
||||
@@ -272,6 +287,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
|
||||
const terminalCwdTracker = useMemo(() => createTerminalCwdTracker(), []);
|
||||
const knownCwdRef = useRef<string | undefined>(undefined);
|
||||
const disposeDataRef = useRef<(() => void) | null>(null);
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
@@ -284,8 +300,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// cancelled retry can't fire a startNewSession after the fact.
|
||||
const retryTokenRef = useRef<symbol | null>(null);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
||||
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
const mouseTrackingRef = useRef(false);
|
||||
const serialLineBufferRef = useRef<string>("");
|
||||
@@ -299,6 +318,26 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const fontWeightFixupDoneRef = useRef(false);
|
||||
|
||||
const captureTerminalLogData = useCallback((data: string) => {
|
||||
const replaySafeData = terminalLogSanitizerRef.current.append(data);
|
||||
if (!replaySafeData) return;
|
||||
connectionLogBufferRef.current.append(replaySafeData);
|
||||
}, []);
|
||||
|
||||
const finalizeTerminalLogData = useCallback(() => {
|
||||
const replaySafeData = terminalLogSanitizerRef.current.finish();
|
||||
if (replaySafeData) {
|
||||
connectionLogBufferRef.current.append(replaySafeData);
|
||||
}
|
||||
return connectionLogBufferRef.current.toString();
|
||||
}, []);
|
||||
|
||||
const writeLocalTerminalData = useCallback((data: string) => {
|
||||
if (!data) return;
|
||||
captureTerminalLogData(data);
|
||||
termRef.current?.write(data);
|
||||
}, [captureTerminalLogData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (xtermRuntimeRef.current) {
|
||||
// Merge global rules with host-level rules
|
||||
@@ -345,10 +384,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
// Autocomplete handler refs (set after hook initialization)
|
||||
// Autocomplete handler refs — populated by <TerminalAutocomplete> so the
|
||||
// xterm runtime (and a few effects here) can drive the hook without making
|
||||
// Terminal re-render on every suggestion update.
|
||||
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
|
||||
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
||||
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
||||
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
@@ -436,20 +478,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const line = serialLineBufferRef.current + "\r";
|
||||
terminalBackend.writeToSession(id, line);
|
||||
serialLineBufferRef.current = "";
|
||||
if (serialConfig?.localEcho) termRef.current?.write("\r\n");
|
||||
if (serialConfig?.localEcho) writeLocalTerminalData("\r\n");
|
||||
} else if (ch === "\x15") {
|
||||
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
|
||||
termRef.current?.write("\b \b".repeat(serialLineBufferRef.current.length));
|
||||
writeLocalTerminalData("\b \b".repeat(serialLineBufferRef.current.length));
|
||||
}
|
||||
serialLineBufferRef.current = "";
|
||||
} else if (ch === "\b" || ch === "\x7f") {
|
||||
if (serialLineBufferRef.current.length > 0) {
|
||||
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
|
||||
if (serialConfig?.localEcho) termRef.current?.write("\b \b");
|
||||
if (serialConfig?.localEcho) writeLocalTerminalData("\b \b");
|
||||
}
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
serialLineBufferRef.current += ch;
|
||||
if (serialConfig?.localEcho) termRef.current?.write(ch);
|
||||
if (serialConfig?.localEcho) writeLocalTerminalData(ch);
|
||||
}
|
||||
}
|
||||
// Still update commandBuffer and broadcast for serial line mode
|
||||
@@ -459,9 +501,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalBackend.writeToSession(id, text);
|
||||
for (const ch of text) {
|
||||
if (ch === "\r") {
|
||||
termRef.current?.write("\r\n");
|
||||
writeLocalTerminalData("\r\n");
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
termRef.current?.write(ch);
|
||||
writeLocalTerminalData(ch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -476,9 +518,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
const cmd = commandBufferRef.current.trim();
|
||||
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
|
||||
commandBufferRef.current = "";
|
||||
const rawCommand = commandBufferRef.current;
|
||||
recordTerminalCommandExecution(rawCommand, {
|
||||
host,
|
||||
sessionId,
|
||||
onCommandExecuted,
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef,
|
||||
}, termRef.current);
|
||||
} else if (ch === "\x15") {
|
||||
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
|
||||
commandBufferRef.current = "";
|
||||
@@ -492,35 +539,58 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostOs: host.os || (host.protocol === "local"
|
||||
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
|
||||
: "linux"),
|
||||
settings: terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined,
|
||||
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
|
||||
protocol: host.protocol,
|
||||
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
|
||||
});
|
||||
// Autocomplete config — the hook itself lives in <TerminalAutocomplete> so
|
||||
// its state updates don't re-render this component (see render below).
|
||||
// For local protocol the effective OS is the client OS: synthetic fallback
|
||||
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
|
||||
// stamp os: "linux", which mis-routes the autocomplete clear sequence to
|
||||
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112).
|
||||
const autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
|
||||
? detectLocalOs(navigator.userAgent || navigator.platform)
|
||||
: (host.os || "linux");
|
||||
const autocompleteSettings = terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined;
|
||||
|
||||
// Wire up autocomplete handler refs so createXTermRuntime can use them
|
||||
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
|
||||
autocompleteInputRef.current = autocomplete.handleInput;
|
||||
autocompleteRepositionRef.current = autocomplete.repositionPopup;
|
||||
const autocompleteClosePopup = autocomplete.closePopup;
|
||||
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
sessionId: sessionRef.current,
|
||||
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
|
||||
const clearTerminalCwd = useCallback(() => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
knownCwdRef.current = undefined;
|
||||
onTerminalCwdChange?.(sessionId, null);
|
||||
}, [onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
||||
|
||||
useEffect(() => {
|
||||
knownCwdRef.current = undefined;
|
||||
}, [sessionId, host.id]);
|
||||
clearTerminalCwd();
|
||||
return clearTerminalCwd;
|
||||
}, [clearTerminalCwd, host.id]);
|
||||
|
||||
// Classify the host's device family from the *detected* distro and the
|
||||
// explicit deviceType only. This intentionally bypasses
|
||||
// getEffectiveHostDistro(): the manual distro override (`distroMode:
|
||||
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
|
||||
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
|
||||
// Huawei host must not silently re-enable POSIX-shell probes against it.
|
||||
// Several features gate on this — the working-directory probe below, the
|
||||
// /etc/os-release probe, and the periodic server-stats poll (#674) —
|
||||
// because each opens an extra exec channel that strict network-device
|
||||
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
|
||||
// whole session (#1043).
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
|
||||
useEffect(() => {
|
||||
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
|
||||
@@ -530,10 +600,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (!sessionRef.current) return;
|
||||
const id = sessionRef.current;
|
||||
if (!id) return;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (!cancelled && result.success && result.cwd) {
|
||||
// The pwd probe opens an extra POSIX-shell exec channel, which strict
|
||||
// network-device CLIs like Huawei VRP answer by closing the whole
|
||||
// session (#1043). Skip it for known network devices; for a brand-new
|
||||
// host (distro not classified yet on the first connect) consult the
|
||||
// SSH banner, which is captured for free at handshake time.
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(id);
|
||||
if (cancelled || id !== sessionRef.current) return;
|
||||
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
|
||||
return;
|
||||
}
|
||||
const result = await terminalBackend.getSessionPwd(id);
|
||||
if (!cancelled && !terminalCwdTracker.getRendererCwd() && result.success && result.cwd) {
|
||||
knownCwdRef.current = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
@@ -545,37 +626,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [host.protocol, status, terminalBackend]);
|
||||
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
autocompleteClosePopup();
|
||||
autocompleteCloseRef.current?.();
|
||||
}
|
||||
}, [isVisible, autocompleteClosePopup]);
|
||||
}, [isVisible]);
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
|
||||
// for hosts classified as network devices (either via explicit
|
||||
// deviceType='network' or via SSH banner detection that populated
|
||||
// host.distro with a network-vendor ID). See #674: polling the stats
|
||||
// command on Cisco / Huawei / Juniper etc. generates one AAA session
|
||||
// log entry per poll because each exec channel is counted as a new
|
||||
// session on those devices.
|
||||
//
|
||||
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
|
||||
// because that honors the manual distro override (`distroMode: 'manual'`
|
||||
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
|
||||
// pinned an "ubuntu" icon on what is actually a Cisco host would
|
||||
// otherwise silently re-enable the polling loop and re-introduce the
|
||||
// AAA log flood this patch is meant to eliminate. The display icon can
|
||||
// still be overridden (see DistroAvatar) — gating uses the raw detected
|
||||
// `host.distro` and the explicit `host.deviceType` only.
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
|
||||
// network devices. See isNetworkDevice above for why the gating uses the
|
||||
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
|
||||
// #674 covers the AAA-log-flood motivation for stats specifically.
|
||||
const isSupportedOs =
|
||||
!isNetworkDevice &&
|
||||
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
||||
@@ -753,12 +819,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hasConnectedRef.current = next === "connected";
|
||||
onStatusChange?.(sessionId, next);
|
||||
};
|
||||
|
||||
const handleTerminalDataCaptureOnce = useCallback((capturedSessionId: string, data: string) => {
|
||||
const captureHandler = onTerminalDataCaptureRef.current;
|
||||
if (!captureHandler || terminalDataCapturedRef.current) return;
|
||||
terminalDataCapturedRef.current = true;
|
||||
captureHandler(capturedSessionId, data);
|
||||
}, []);
|
||||
const replaySafeLogData = finalizeTerminalLogData();
|
||||
const capturedData = replaySafeLogData || data;
|
||||
captureHandler(capturedSessionId, capturedData);
|
||||
}, [finalizeTerminalLogData]);
|
||||
|
||||
const cleanupSession = () => {
|
||||
disposeDataRef.current?.();
|
||||
@@ -810,6 +879,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fitAddonRef,
|
||||
serializeAddonRef,
|
||||
pendingAuthRef,
|
||||
promptLineBreakStateRef,
|
||||
updateStatus,
|
||||
setStatus,
|
||||
setError,
|
||||
@@ -821,6 +891,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
clearTerminalCwd();
|
||||
// SSH: always sync. Its backend starts in utf-8 regardless of
|
||||
// host.charset, so the push is what keeps the UI state aligned
|
||||
// across reconnects — including localhost SSH targets, hence
|
||||
@@ -844,8 +915,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onSessionExit: (closedSessionId, evt) => {
|
||||
clearTerminalCwd();
|
||||
onSessionExit?.(closedSessionId, evt);
|
||||
},
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
@@ -855,6 +930,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
terminalDataCapturedRef.current = false;
|
||||
connectionLogBufferRef.current.reset();
|
||||
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
pendingOutputScrollRef.current = false;
|
||||
@@ -862,6 +939,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setShowLogs(false);
|
||||
setIsCancelling(false);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
promptLineBreakStateRef.current = createPromptLineBreakState();
|
||||
|
||||
const boot = async () => {
|
||||
try {
|
||||
@@ -886,13 +964,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
statusRef,
|
||||
onCommandExecuted,
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef,
|
||||
setIsSearchOpen,
|
||||
// Serial-specific options
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
onCwdChange: (cwd: string) => {
|
||||
terminalCwdTracker.setRendererCwd(cwd);
|
||||
knownCwdRef.current = cwd;
|
||||
onTerminalCwdChange?.(sessionId, cwd);
|
||||
},
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
// Autocomplete integration
|
||||
@@ -1157,11 +1239,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: 0;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
|
||||
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
|
||||
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
// Changing the font can leave the WebGL renderer drawing stale glyphs from
|
||||
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
|
||||
// Clear the texture atlas so glyphs re-rasterize with the new font.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
@@ -1174,6 +1263,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!isVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
// Recover the WebGL renderer now that this tab is visible again. Hidden
|
||||
// panes stay mounted off-screen (visibility:hidden) so each keeps a live
|
||||
// WebGL context; creating another terminal's context — or the GPU dropping
|
||||
// a non-composited off-screen canvas — can leave this terminal's drawing
|
||||
// buffer corrupted ("花屏", issue #1063). Because a hidden pane keeps its
|
||||
// dimensions, becoming visible triggers no resize and therefore no redraw,
|
||||
// so the corruption persists until the user resizes the window. Force the
|
||||
// same recovery a resize performs: clear the texture atlas (no-op on the
|
||||
// DOM renderer) and synchronously repaint every row.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
const visibleTerm = termRef.current;
|
||||
if (visibleTerm) forceSyncRenderAfterResize(visibleTerm);
|
||||
if (pendingOutputScrollRef.current) {
|
||||
termRef.current?.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
@@ -1395,6 +1496,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const handleContextMenuCapture = (e: MouseEvent) => {
|
||||
if (!mouseTrackingRef.current) return;
|
||||
if (statusRef.current !== 'connected') return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
@@ -1418,7 +1520,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const handleMouseUpCapture = (e: MouseEvent) => {
|
||||
if (e.button === 2 && mouseTrackingRef.current) {
|
||||
if (e.button === 2 && mouseTrackingRef.current && statusRef.current === 'connected') {
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
@@ -1486,10 +1588,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
|
||||
// Broadcast the exact bytes the active session receives so peers mirror it,
|
||||
// including the bracketed-paste wrapping and the auto-run \r. Broadcasting
|
||||
// the raw (un-wrapped) form would let a multi-line noAutoRun snippet run
|
||||
// line-by-line on peers, since handleBroadcastInput writes bytes directly
|
||||
// without re-wrapping. Without broadcasting at all, accepting a snippet in
|
||||
// broadcast mode would clear peer input (the clear keystrokes already go
|
||||
// through the broadcast-aware path) but never send the command.
|
||||
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(data, sessionId);
|
||||
}
|
||||
|
||||
terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterProgrammaticInput(data);
|
||||
term.focus();
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
||||
|
||||
// Only register the snippet executor once the terminal session is ready.
|
||||
// Before that, TerminalLayer falls back to raw writeToSession which is the
|
||||
@@ -1505,11 +1618,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
sourceSessionId: sessionId,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
onHasSelectionChange: setHasSelection,
|
||||
disableBracketedPasteRef,
|
||||
scrollOnPasteRef,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
});
|
||||
// Kept fresh on every render so the mouseTracking capture handler at
|
||||
// handleContextMenuCapture (which is bound once per sessionId) can
|
||||
@@ -1529,17 +1643,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleOpenSFTP = async () => {
|
||||
if (onOpenSftp) {
|
||||
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
onOpenSftp(host, initialPath, undefined, sessionId);
|
||||
return;
|
||||
}
|
||||
@@ -1752,17 +1856,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
} else {
|
||||
// Remote terminal: Trigger SFTP upload via parent
|
||||
if (onOpenSftp) {
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
}
|
||||
}
|
||||
@@ -1821,6 +1915,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSelectWord={terminalContextActions.onSelectWord}
|
||||
onSplitHorizontal={onSplitHorizontal}
|
||||
onSplitVertical={onSplitVertical}
|
||||
isReconnectable={status === "disconnected"}
|
||||
onReconnect={handleRetry}
|
||||
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
||||
>
|
||||
<div
|
||||
@@ -1879,21 +1975,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
/>
|
||||
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 p-0.5 rounded hover:bg-[color:var(--terminal-toolbar-btn-hover)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(host.hostname).then(() => {
|
||||
toast.success(t("terminal.statusbar.copyHostname.toast", { hostname: host.hostname }));
|
||||
}).catch(() => {
|
||||
toast.error(t("terminal.statusbar.copyHostname.error"));
|
||||
});
|
||||
}}
|
||||
title={t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}
|
||||
aria-label={t("terminal.statusbar.copyHostname.label")}
|
||||
>
|
||||
<Copy size={10} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 p-0.5 rounded hover:bg-[color:var(--terminal-toolbar-btn-hover)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(host.hostname).then(() => {
|
||||
toast.success(t("terminal.statusbar.copyHostname.toast", { hostname: host.hostname }));
|
||||
}).catch(() => {
|
||||
toast.error(t("terminal.statusbar.copyHostname.error"));
|
||||
});
|
||||
}}
|
||||
aria-label={t("terminal.statusbar.copyHostname.label")}
|
||||
>
|
||||
<Copy size={10} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{/* Server Stats Display */}
|
||||
@@ -1904,7 +2004,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.cpu")}
|
||||
aria-label={t("terminal.serverStats.cpu")}
|
||||
>
|
||||
<Cpu size={10} className="flex-shrink-0" />
|
||||
<span>
|
||||
@@ -1973,7 +2073,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.memory")}
|
||||
aria-label={t("terminal.serverStats.memory")}
|
||||
>
|
||||
<MemoryStick size={10} className="flex-shrink-0" />
|
||||
<span>
|
||||
@@ -1995,12 +2095,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
{serverStats.memTotal !== null && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
|
||||
{/* Used (green) */}
|
||||
{/* Used (green) — exact value shown in legend below */}
|
||||
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memUsed")}: ${(serverStats.memUsed / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
{/* Buffers (blue) */}
|
||||
@@ -2008,7 +2107,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div
|
||||
className="h-full bg-blue-500"
|
||||
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memBuffers")}: ${(serverStats.memBuffers / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
{/* Cached (amber/orange) */}
|
||||
@@ -2016,7 +2114,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div
|
||||
className="h-full bg-amber-500"
|
||||
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memCached")}: ${(serverStats.memCached / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -2050,7 +2147,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div
|
||||
className="h-full bg-rose-500"
|
||||
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -2083,9 +2179,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-shrink-0 font-mono truncate max-w-[140px]" title={proc.command}>
|
||||
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-shrink-0 font-mono truncate max-w-[140px] cursor-default">
|
||||
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{proc.command}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2099,7 +2200,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.disk")}
|
||||
aria-label={t("terminal.serverStats.disk")}
|
||||
>
|
||||
<HardDrive size={10} className="flex-shrink-0" />
|
||||
<span className={cn(
|
||||
@@ -2127,9 +2228,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
{serverStats.disks.map((disk, index) => (
|
||||
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]" title={disk.mountPoint}>
|
||||
{disk.mountPoint}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px] cursor-default">
|
||||
{disk.mountPoint}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{disk.mountPoint}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className={cn(
|
||||
"text-[11px] font-medium whitespace-nowrap",
|
||||
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
|
||||
@@ -2161,7 +2267,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.network")}
|
||||
aria-label={t("terminal.serverStats.network")}
|
||||
>
|
||||
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
|
||||
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
|
||||
@@ -2205,40 +2311,48 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{inWorkspace && onToggleBroadcast && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
|
||||
"bg-transparent hover:bg-transparent",
|
||||
isBroadcastEnabled && "text-green-500",
|
||||
)}
|
||||
onClick={onToggleBroadcast}
|
||||
title={
|
||||
isBroadcastEnabled
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
|
||||
"bg-transparent hover:bg-transparent",
|
||||
isBroadcastEnabled && "text-green-500",
|
||||
)}
|
||||
onClick={onToggleBroadcast}
|
||||
aria-label={
|
||||
isBroadcastEnabled
|
||||
? t("terminal.toolbar.broadcastDisable")
|
||||
: t("terminal.toolbar.broadcastEnable")
|
||||
}
|
||||
>
|
||||
<Radio size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isBroadcastEnabled
|
||||
? t("terminal.toolbar.broadcastDisable")
|
||||
: t("terminal.toolbar.broadcastEnable")
|
||||
}
|
||||
aria-label={
|
||||
isBroadcastEnabled
|
||||
? t("terminal.toolbar.broadcastDisable")
|
||||
: t("terminal.toolbar.broadcastEnable")
|
||||
}
|
||||
>
|
||||
<Radio size={12} />
|
||||
</Button>
|
||||
: t("terminal.toolbar.broadcastEnable")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{inWorkspace && !isFocusMode && onExpandToFocus && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
|
||||
onClick={onExpandToFocus}
|
||||
title={t("terminal.toolbar.focusMode")}
|
||||
aria-label={t("terminal.toolbar.focusMode")}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
|
||||
onClick={onExpandToFocus}
|
||||
aria-label={t("terminal.toolbar.focusMode")}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("terminal.toolbar.focusMode")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{renderControls({ showClose: inWorkspace })}
|
||||
</div>
|
||||
@@ -2271,29 +2385,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
|
||||
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
|
||||
ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={autocomplete.state.suggestions}
|
||||
selectedIndex={autocomplete.state.selectedIndex}
|
||||
position={autocomplete.state.popupPosition}
|
||||
cursorLineTop={autocomplete.state.popupCursorLineTop}
|
||||
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
|
||||
visible={autocomplete.state.popupVisible}
|
||||
expandUpward={autocomplete.state.expandUpward}
|
||||
themeColors={effectiveTheme.colors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={autocomplete.state.subDirPanels}
|
||||
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
onDismiss={autocompleteClosePopup}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
{/* Autocomplete — owns the hook + popup in its own component so
|
||||
suggestion/selection updates don't re-render Terminal. Mounted
|
||||
unconditionally; it gates the popup on `visible` internally. */}
|
||||
<TerminalAutocomplete
|
||||
termRef={termRef}
|
||||
sessionId={sessionId}
|
||||
hostId={host.id}
|
||||
hostOs={autocompleteHostOs}
|
||||
settings={autocompleteSettings}
|
||||
protocol={host.protocol}
|
||||
getCwd={() => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current}
|
||||
onAcceptText={(text) => autocompleteAcceptTextRef.current?.(text)}
|
||||
snippets={snippets}
|
||||
onAcceptSnippet={(snippet) => executeSnippetCommand(snippet.command, snippet.noAutoRun)}
|
||||
visible={isVisible}
|
||||
themeColors={effectiveTheme.colors}
|
||||
containerRef={containerRef}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
keyEventRef={autocompleteKeyEventRef}
|
||||
inputRef={autocompleteInputRef}
|
||||
repositionRef={autocompleteRepositionRef}
|
||||
closeRef={autocompleteCloseRef}
|
||||
/>
|
||||
|
||||
{/* OSC-52 clipboard read prompt */}
|
||||
{osc52ReadPromptVisible && (
|
||||
@@ -2384,6 +2498,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ZMODEM overwrite conflict dialog */}
|
||||
{zmodem.overwriteRequest && (
|
||||
<ZmodemOverwriteDialog
|
||||
filename={zmodem.overwriteRequest.filename}
|
||||
onRespond={zmodem.respondOverwrite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
|
||||
@@ -35,6 +35,8 @@ const baseProps = {
|
||||
onAddKnownHost: () => {},
|
||||
onToggleWorkspaceViewMode: () => {},
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
isBroadcastEnabled: () => false,
|
||||
onToggleBroadcast: () => {},
|
||||
onSplitSession: () => {},
|
||||
toggleScriptsSidePanelRef: { current: null },
|
||||
};
|
||||
@@ -96,3 +98,23 @@ test("TerminalLayer re-renders when proxy profiles change", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when broadcast state changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, isBroadcastEnabled: () => true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, onToggleBroadcast: () => {} } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,7 @@ interface TextEditorModalProps {
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
}
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
|
||||
*/
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Check, Wand2 } from 'lucide-react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../infrastructure/config/terminalThemes';
|
||||
import { TERMINAL_THEME_AUTO } from '../domain/terminalAppearance';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
|
||||
// Memoized theme item component
|
||||
export const ThemeItem = memo(({
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
@@ -53,13 +54,18 @@ ThemeItem.displayName = 'ThemeItem';
|
||||
interface ThemeListProps {
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
/** Restrict the list to a single type; omit to show both sections. */
|
||||
filterType?: 'dark' | 'light';
|
||||
/** Render an "Auto (match app theme)" entry at the top. */
|
||||
showAutoOption?: boolean;
|
||||
}
|
||||
|
||||
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
|
||||
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect, filterType, showAutoOption }) => {
|
||||
const { t } = useI18n();
|
||||
const customThemes = useCustomThemes();
|
||||
const deletedSelectedTheme = useMemo(
|
||||
() => (selectedThemeId
|
||||
&& selectedThemeId !== TERMINAL_THEME_AUTO
|
||||
&& !isUiMatchTerminalThemeId(selectedThemeId)
|
||||
&& !TERMINAL_THEMES.some((theme) => theme.id === selectedThemeId)
|
||||
&& !customThemes.some((theme) => theme.id === selectedThemeId)
|
||||
@@ -80,8 +86,33 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
const visibleCustomThemes = filterType
|
||||
? customThemes.filter(theme => theme.type === filterType)
|
||||
: customThemes;
|
||||
const isAutoSelected = selectedThemeId === TERMINAL_THEME_AUTO;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAutoOption && (
|
||||
<button
|
||||
onClick={() => onSelect(TERMINAL_THEME_AUTO)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 mb-3 rounded-md text-left transition-all',
|
||||
isAutoSelected ? 'bg-primary/10' : 'hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<div className="w-12 h-8 rounded-[4px] flex-shrink-0 flex items-center justify-center border border-border/50 bg-gradient-to-br from-muted to-background">
|
||||
<Wand2 size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isAutoSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{t('settings.terminal.theme.auto')}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('settings.terminal.theme.autoDesc')}</div>
|
||||
</div>
|
||||
{isAutoSelected && <Check size={16} className="text-primary flex-shrink-0" />}
|
||||
</button>
|
||||
)}
|
||||
{hiddenSelectedTheme && (
|
||||
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
|
||||
@@ -105,6 +136,7 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
|
||||
</div>
|
||||
)}
|
||||
{/* Dark Themes Section */}
|
||||
{(!filterType || filterType === 'dark') && (
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
@@ -120,8 +152,10 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Light Themes Section */}
|
||||
{(!filterType || filterType === 'light') && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
@@ -137,15 +171,16 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Themes Section */}
|
||||
{customThemes.length > 0 && (
|
||||
{visibleCustomThemes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{customThemes.map(theme => (
|
||||
{visibleCustomThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -190,5 +190,3 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficDiagram;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSessionState } from "../application/state/useSessionState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
@@ -78,28 +79,31 @@ const WorkspaceGroup: React.FC<{
|
||||
{expanded && (
|
||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
// Jump to session (using session id)
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted/60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
<Tooltip key={s.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Jump to session (using session id)
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted/60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -219,17 +223,20 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
<span className="text-sm font-medium">Netcatty</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenMain}
|
||||
title={t("tray.openMainWindow")}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenMain}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("tray.openMainWindow")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -277,27 +284,30 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
))}
|
||||
{/* Solo sessions */}
|
||||
{soloSessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
<Tooltip key={s.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,16 +317,20 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
{activeSession && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 h-8"
|
||||
title={activeSession.hostLabel || activeSession.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(activeSession.id);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 h-8"
|
||||
onClick={() => {
|
||||
void jumpToSession(activeSession.id);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{activeSession.hostLabel || activeSession.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -332,55 +346,58 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
: `${rule.localPort} → ${rule.remoteHost}:${rule.remotePort}`);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={rule.id}
|
||||
disabled={isConnecting}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!rawHost) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
const resolveEffectiveHost = (host: Host) => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
};
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
isConnecting ? "opacity-60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={
|
||||
rule.status === "active"
|
||||
? "success"
|
||||
: rule.status === "connecting"
|
||||
? "warning"
|
||||
: rule.status === "error"
|
||||
? "error"
|
||||
: "neutral"
|
||||
}
|
||||
spinning={rule.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`tray.status.${rule.status}`)}
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip key={rule.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
disabled={isConnecting}
|
||||
onClick={() => {
|
||||
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!rawHost) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
const resolveEffectiveHost = (host: Host) => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
};
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
isConnecting ? "opacity-60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={
|
||||
rule.status === "active"
|
||||
? "success"
|
||||
: rule.status === "connecting"
|
||||
? "warning"
|
||||
: rule.status === "error"
|
||||
? "error"
|
||||
: "neutral"
|
||||
}
|
||||
spinning={rule.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`tray.status.${rule.status}`)}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
142
components/VaultView.sortPersistence.test.tsx
Normal file
142
components/VaultView.sortPersistence.test.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
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 { STORAGE_KEY_VAULT_HOSTS_SORT_MODE } from "../infrastructure/config/storageKeys.ts";
|
||||
import type { Host, SSHKey } from "../types.ts";
|
||||
import { VaultView } from "./VaultView.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const installStorageStub = (sortMode: string | null) => {
|
||||
const values = new Map<string, string>();
|
||||
if (sortMode !== null) {
|
||||
values.set(STORAGE_KEY_VAULT_HOSTS_SORT_MODE, sortMode);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const host = (id: string, label: string, createdAt: number, group = ""): Host => ({
|
||||
id,
|
||||
label,
|
||||
hostname: `${id}.example.com`,
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
authMethod: "password",
|
||||
createdAt,
|
||||
group,
|
||||
});
|
||||
|
||||
const fallbackKey: SSHKey = {
|
||||
id: "key-1",
|
||||
label: "Fallback key",
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
source: "generated",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const renderVault = (sortMode: string | null, hosts: Host[]) => {
|
||||
installStorageStub(sortMode);
|
||||
const noop = () => {};
|
||||
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(VaultView, {
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
shellHistory: [],
|
||||
connectionLogs: [],
|
||||
managedSources: [],
|
||||
sessionCount: 0,
|
||||
hotkeyScheme: "mac",
|
||||
keyBindings: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onOpenSettings: noop,
|
||||
onOpenQuickSwitcher: noop,
|
||||
onCreateLocalTerminal: noop,
|
||||
onDeleteHost: noop,
|
||||
onConnect: noop,
|
||||
onUpdateHosts: noop,
|
||||
onUpdateKeys: noop,
|
||||
onImportOrReuseKey: () => fallbackKey,
|
||||
onUpdateIdentities: noop,
|
||||
onUpdateProxyProfiles: noop,
|
||||
onUpdateSnippets: noop,
|
||||
onUpdateSnippetPackages: noop,
|
||||
onUpdateCustomGroups: noop,
|
||||
onUpdateKnownHosts: noop,
|
||||
onUpdateManagedSources: noop,
|
||||
onConvertKnownHost: noop,
|
||||
onToggleConnectionLogSaved: noop,
|
||||
onDeleteConnectionLog: noop,
|
||||
onClearUnsavedConnectionLogs: noop,
|
||||
onOpenLogView: noop,
|
||||
groupConfigs: [],
|
||||
onUpdateGroupConfigs: noop,
|
||||
showRecentHosts: false,
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
test("Hosts sort mode is restored from storage", () => {
|
||||
const markup = renderVault("za", [
|
||||
host("alpha", "Alpha Host", 1),
|
||||
host("zulu", "Zulu Host", 2),
|
||||
]);
|
||||
|
||||
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
|
||||
});
|
||||
|
||||
test("Hosts grouped sort mode is restored from storage", () => {
|
||||
const markup = renderVault("group", [
|
||||
host("beta", "Beta Host", 1, "Beta Group"),
|
||||
host("alpha", "Alpha Host", 2, "Alpha Group"),
|
||||
]);
|
||||
|
||||
assert.match(
|
||||
markup,
|
||||
/<span class="text-sm font-medium text-muted-foreground">Alpha Group<\/span><span class="text-xs text-muted-foreground\/60">\(1\)<\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Hosts sort mode falls back safely when storage contains an invalid value", () => {
|
||||
const markup = renderVault("unknown-sort", [
|
||||
host("zulu", "Zulu Host", 2),
|
||||
host("alpha", "Alpha Host", 1),
|
||||
]);
|
||||
|
||||
assert.ok(markup.indexOf("Alpha Host") < markup.indexOf("Zulu Host"));
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import React, { Suspense, lazy, memo, startTransition, useCallback, useEffect, u
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useStoredString } from "../application/state/useStoredString";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
@@ -50,6 +51,7 @@ import { upsertKnownHost } from "../domain/knownHosts";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
||||
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
|
||||
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
||||
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
||||
@@ -70,7 +72,6 @@ import {
|
||||
SSHKey,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
TerminalSession,
|
||||
} from "../types";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
@@ -122,6 +123,13 @@ type DropTarget =
|
||||
| { kind: "root" }
|
||||
| { kind: "group"; path: string };
|
||||
|
||||
const isSortMode = (value: string): value is SortMode =>
|
||||
value === "az" ||
|
||||
value === "za" ||
|
||||
value === "newest" ||
|
||||
value === "oldest" ||
|
||||
value === "group";
|
||||
|
||||
// Props without isActive - it's now subscribed internally
|
||||
interface VaultViewProps {
|
||||
hosts: Host[];
|
||||
@@ -135,7 +143,7 @@ interface VaultViewProps {
|
||||
shellHistory: ShellHistoryEntry[];
|
||||
connectionLogs: ConnectionLog[];
|
||||
managedSources: ManagedSource[];
|
||||
sessions: TerminalSession[];
|
||||
sessionCount: number;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
terminalThemeId: string;
|
||||
@@ -187,7 +195,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
sessions,
|
||||
sessionCount,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
terminalThemeId,
|
||||
@@ -281,7 +289,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
"grid",
|
||||
);
|
||||
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [sortMode, setSortMode] = useStoredString<SortMode>(
|
||||
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
||||
"az",
|
||||
isSortMode,
|
||||
);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
@@ -1907,21 +1919,25 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onChange={setSortMode}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Button
|
||||
variant={isMultiSelectMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearHostSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
title={t("vault.hosts.multiSelect")}
|
||||
>
|
||||
<CheckSquare size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isMultiSelectMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearHostSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckSquare size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("vault.hosts.multiSelect")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* New Host split button — collapses with an animation when the
|
||||
host details / new-host aside panel is open, since the button
|
||||
@@ -2229,6 +2245,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<ContextMenuItem onClick={() => handleEditHost(host)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
|
||||
<Pin className="mr-2 h-4 w-4" /> {t('vault.hosts.unpin')}
|
||||
</ContextMenuItem>
|
||||
@@ -2328,6 +2350,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<ContextMenuItem onClick={() => handleEditHost(host)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
|
||||
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
|
||||
</ContextMenuItem>
|
||||
@@ -2495,7 +2523,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
|
||||
</span>
|
||||
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
|
||||
{t("vault.hosts.header.live", { count: sessions.length })}
|
||||
{t("vault.hosts.header.live", { count: sessionCount })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2887,6 +2915,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts={hosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
managedSources={managedSources}
|
||||
onSave={(k) => onUpdateKeys([...keys, k])}
|
||||
onUpdate={(k) =>
|
||||
@@ -3275,7 +3304,7 @@ export const vaultViewAreEqual = (
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
prev.shellHistory === next.shellHistory &&
|
||||
prev.connectionLogs === next.connectionLogs &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.sessionCount === next.sessionCount &&
|
||||
prev.managedSources === next.managedSources &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.terminalThemeId === next.terminalThemeId &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import { code } from '@streamdown/code';
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { createSafeCodeHighlighter } from './streamdownCodeHighlighter';
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: 'user' | 'assistant' | 'system' | 'tool';
|
||||
@@ -46,21 +47,8 @@ export const MessageContent = ({ children, className, from, ...props }: MessageC
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<'div'>;
|
||||
|
||||
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const streamdownPlugins = { cjk, code };
|
||||
const safeCode = createSafeCodeHighlighter(code);
|
||||
const streamdownPlugins = { cjk, code: safeCode };
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -145,37 +144,6 @@ export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps
|
||||
);
|
||||
PromptInputTools.displayName = 'PromptInputTools';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputButton (toolbar button with optional tooltip)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputButtonProps extends ComponentProps<typeof InputGroupButton> {
|
||||
tooltip?: ReactNode;
|
||||
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export const PromptInputButton = forwardRef<HTMLButtonElement, PromptInputButtonProps>(
|
||||
({ tooltip, tooltipSide = 'top', ...props }, ref) => {
|
||||
const button = <InputGroupButton ref={ref} {...props} />;
|
||||
|
||||
if (!tooltip) return button;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputButton.displayName = 'PromptInputButton';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSubmit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
|
||||
|
||||
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
|
||||
@@ -244,4 +212,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
|
||||
},
|
||||
);
|
||||
PromptInputSubmit.displayName = 'PromptInputSubmit';
|
||||
|
||||
|
||||
76
components/ai-elements/streamdownCodeHighlighter.ts
Normal file
76
components/ai-elements/streamdownCodeHighlighter.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
CodeHighlighterPlugin,
|
||||
HighlightOptions,
|
||||
HighlightResult,
|
||||
} from 'streamdown';
|
||||
import type { BundledLanguage } from 'shiki';
|
||||
|
||||
const PLAIN_TEXT_LANGUAGES = new Set([
|
||||
'',
|
||||
'plain',
|
||||
'plaintext',
|
||||
'text',
|
||||
'txt',
|
||||
]);
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
||||
cfg: 'ini',
|
||||
conf: 'ini',
|
||||
config: 'ini',
|
||||
};
|
||||
|
||||
export const createPlainCodeHighlightResult = (source: string): HighlightResult => {
|
||||
const code = source.replace(/\n+$/, '');
|
||||
return {
|
||||
bg: 'transparent',
|
||||
fg: 'inherit',
|
||||
tokens: code.split('\n').map((line) => [
|
||||
{
|
||||
content: line,
|
||||
color: 'inherit',
|
||||
bgColor: 'transparent',
|
||||
htmlStyle: {},
|
||||
offset: 0,
|
||||
},
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeLanguageKey = (language: string): string =>
|
||||
language.trim().toLowerCase();
|
||||
|
||||
export const resolveSupportedCodeLanguage = (
|
||||
highlighter: CodeHighlighterPlugin,
|
||||
language: string,
|
||||
): BundledLanguage | null => {
|
||||
const key = normalizeLanguageKey(language);
|
||||
if (PLAIN_TEXT_LANGUAGES.has(key)) return null;
|
||||
|
||||
const direct = key as BundledLanguage;
|
||||
if (highlighter.supportsLanguage(direct)) return direct;
|
||||
|
||||
const alias = LANGUAGE_ALIASES[key];
|
||||
if (alias && highlighter.supportsLanguage(alias)) return alias;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const createSafeCodeHighlighter = (
|
||||
highlighter: CodeHighlighterPlugin,
|
||||
): CodeHighlighterPlugin => ({
|
||||
...highlighter,
|
||||
supportsLanguage(language) {
|
||||
return resolveSupportedCodeLanguage(highlighter, language) !== null;
|
||||
},
|
||||
highlight(options: HighlightOptions, callback?: (result: HighlightResult) => void) {
|
||||
const supportedLanguage = resolveSupportedCodeLanguage(highlighter, options.language);
|
||||
if (!supportedLanguage) {
|
||||
return createPlainCodeHighlightResult(options.code);
|
||||
}
|
||||
|
||||
return highlighter.highlight(
|
||||
{ ...options, language: supportedLanguage },
|
||||
callback,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { cn } from '../../lib/utils';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
@@ -142,9 +143,14 @@ export const ToolCall = ({
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
{name === 'terminal_execute' && args?.command ? (
|
||||
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
|
||||
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{String(args.command)}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface AgentSelectorProps {
|
||||
currentAgentId: string;
|
||||
@@ -80,6 +81,7 @@ const DiscoveredAgentRow: React.FC<{
|
||||
agent: DiscoveredAgent;
|
||||
onEnable: () => void;
|
||||
}> = ({ agent, onEnable }) => {
|
||||
const { t } = useI18n();
|
||||
const agentLike: AgentInfo = {
|
||||
id: `discovered_${agent.command}`,
|
||||
name: agent.name,
|
||||
@@ -98,13 +100,17 @@ const DiscoveredAgentRow: React.FC<{
|
||||
{agent.version || agent.path}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
|
||||
title={`Enable ${agent.name}`}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.enableAgent', { name: agent.name })}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -250,14 +256,18 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<SectionLabel
|
||||
action={
|
||||
onRediscover && (
|
||||
<button
|
||||
onClick={onRediscover}
|
||||
disabled={isDiscovering}
|
||||
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
|
||||
title={t('ai.chat.rescan')}
|
||||
>
|
||||
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onRediscover}
|
||||
disabled={isDiscovering}
|
||||
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.rescan')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -20,11 +20,37 @@ import {
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
const MODEL_PICKER_MAX_WIDTH = 360;
|
||||
// Slightly wider for the provider picker so the per-row default-model
|
||||
// caption doesn't truncate.
|
||||
const PROVIDER_PICKER_MAX_WIDTH = 320;
|
||||
|
||||
/**
|
||||
* Provider picker payload used by Catty Agent. When set, the model chip
|
||||
* switches to a flat provider list (provider icon + name + the provider's
|
||||
* configured default model as caption) in place of the generic Cpu glyph
|
||||
* + model-preset dropdown. Each provider exposes a single model — its
|
||||
* `defaultModel` — so a two-level menu would be empty noise; picking a
|
||||
* provider implicitly picks its model.
|
||||
*/
|
||||
export interface ProviderSwitcherConfig {
|
||||
/** Every configured provider — Settings-level visibility, not the
|
||||
* `enabled` toggle, since the user expects to swap between everything
|
||||
* they've set up. */
|
||||
providers: ProviderConfig[];
|
||||
/** Currently bound provider id (falls back to providers[0] when missing). */
|
||||
selectedProviderId?: string;
|
||||
/** Currently bound model id under the selected provider. */
|
||||
selectedModelId?: string;
|
||||
/** Fires when the user picks a (providerId, modelId) pair. */
|
||||
onSelect: (providerId: string, modelId: string) => void;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
@@ -63,6 +89,13 @@ interface ChatInputProps {
|
||||
permissionMode?: AIPermissionMode;
|
||||
/** Callback when user changes permission mode */
|
||||
onPermissionModeChange?: (mode: AIPermissionMode) => void;
|
||||
/**
|
||||
* Provider→model two-level picker payload. When provided, replaces the
|
||||
* single-list model dropdown with a provider-aware picker. Used for the
|
||||
* Catty Agent only — external ACP agents (Claude/Codex) keep the
|
||||
* `modelPresets` dropdown because their provider is wired inside the CLI.
|
||||
*/
|
||||
providerSwitcher?: ProviderSwitcherConfig;
|
||||
}
|
||||
|
||||
const ChatInput: React.FC<ChatInputProps> = ({
|
||||
@@ -89,6 +122,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onRemoveUserSkill,
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
providerSwitcher,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -354,16 +388,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
})();
|
||||
const selectedBaseModelId = selectedPreset?.id;
|
||||
const modelLabel = selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel');
|
||||
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
|
||||
// Provider switcher mode (Catty Agent): two-column popover, chip carries
|
||||
// the provider's icon + name + model name. Falls back to the existing
|
||||
// single-list model dropdown for ACP agents.
|
||||
const hasProviderSwitcher = !!providerSwitcher && providerSwitcher.providers.length > 0;
|
||||
// Resolve to the actually-bound provider only — no `?? providers[0]`
|
||||
// fallback, since a provider that isn't really bound will still hit the
|
||||
// `!sendActiveProvider` guard at send time. Faking a selection in the
|
||||
// chip would lie about a state the rest of the system treats as empty.
|
||||
const selectedSwitcherProvider = hasProviderSwitcher
|
||||
? providerSwitcher!.providers.find((p) => p.id === providerSwitcher!.selectedProviderId)
|
||||
: undefined;
|
||||
const providerSwitcherChipLabel = hasProviderSwitcher
|
||||
? (selectedSwitcherProvider
|
||||
? (providerSwitcher!.selectedModelId
|
||||
? `${selectedSwitcherProvider.name} · ${providerSwitcher!.selectedModelId}`
|
||||
: selectedSwitcherProvider.name)
|
||||
: t('ai.chat.selectProvider'))
|
||||
: '';
|
||||
const modelLabel = hasProviderSwitcher
|
||||
? providerSwitcherChipLabel
|
||||
: (selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel'));
|
||||
const hasModelPicker = hasProviderSwitcher || (modelPresets.length > 0 && !!onModelSelect);
|
||||
const popoverMaxWidth = hasProviderSwitcher ? PROVIDER_PICKER_MAX_WIDTH : MODEL_PICKER_MAX_WIDTH;
|
||||
const chipClassName =
|
||||
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
|
||||
const selectedSkillChipClassName =
|
||||
'inline-flex h-7 items-center gap-1.5 rounded-full border border-primary/18 bg-primary/8 pl-2.5 pr-1.5 text-[11px] font-medium text-foreground/86 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]';
|
||||
const iconButtonClassName =
|
||||
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
|
||||
'h-6 w-6 shrink-0 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
@@ -415,24 +470,27 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div className="px-3 pt-3 pb-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedUserSkills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className={selectedSkillChipClassName}
|
||||
title={skill.description || skill.name || skill.slug}
|
||||
>
|
||||
<Package size={11} className="text-primary/72 shrink-0" />
|
||||
<span className="truncate max-w-[180px]">
|
||||
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUserSkill?.(skill.slug)}
|
||||
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
|
||||
aria-label={`Remove skill ${skill.name || skill.slug}`}
|
||||
>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
<Tooltip key={skill.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={selectedSkillChipClassName}
|
||||
>
|
||||
<Package size={11} className="text-primary/72 shrink-0" />
|
||||
<span className="truncate max-w-[180px]">
|
||||
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUserSkill?.(skill.slug)}
|
||||
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
|
||||
aria-label={`Remove skill ${skill.name || skill.slug}`}
|
||||
>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{skill.description || skill.name || skill.slug}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,14 +508,18 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
].filter(Boolean).join(' ')}
|
||||
maxLength={100000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<Expand size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
|
||||
>
|
||||
<Expand size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{expanded ? t('ai.chat.collapse') : t('ai.chat.expand')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* @ mention popover */}
|
||||
@@ -556,26 +618,30 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
|
||||
<PromptInputTools className="gap-1 flex-wrap">
|
||||
<button
|
||||
ref={attachBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showAttachMenu) {
|
||||
const rect = attachBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('attach');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
title="Attach"
|
||||
aria-label="Attach file"
|
||||
aria-expanded={showAttachMenu}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
<PromptInputTools className="gap-1 min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={attachBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showAttachMenu) {
|
||||
const rect = attachBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('attach');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
aria-label={t('ai.chat.attach')}
|
||||
aria-expanded={showAttachMenu}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.attach')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showAttachMenu && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
@@ -643,7 +709,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
// Clamp so the popover stays inside the viewport when
|
||||
// the chip is near the right edge of a narrow AI side
|
||||
// panel.
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8));
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - popoverMaxWidth - 8));
|
||||
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
|
||||
}
|
||||
setActiveMenu('model');
|
||||
@@ -651,12 +717,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
|
||||
aria-label="Select model"
|
||||
className={`${chipClassName} min-w-0 ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
|
||||
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
|
||||
aria-expanded={showModelPicker}
|
||||
>
|
||||
<Cpu size={11} className="text-muted-foreground/64" />
|
||||
<span className="truncate max-w-[82px]">{modelLabel}</span>
|
||||
{hasProviderSwitcher && selectedSwitcherProvider ? (
|
||||
<ProviderIconBadge provider={selectedSwitcherProvider} size="xs" />
|
||||
) : (
|
||||
<Cpu size={11} className="text-muted-foreground/64" />
|
||||
)}
|
||||
<span className={`truncate min-w-0 ${hasProviderSwitcher ? 'max-w-[180px]' : 'max-w-[82px]'}`}>{modelLabel}</span>
|
||||
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{showModelPicker && hasModelPicker && menuPos && createPortal(
|
||||
@@ -665,12 +735,58 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
|
||||
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: popoverMaxWidth }}
|
||||
onMouseLeave={() => setHoveredModelId(null)}
|
||||
>
|
||||
{modelPresets.map(preset => {
|
||||
{hasProviderSwitcher ? (
|
||||
<div className="min-w-[260px] max-h-[320px] overflow-y-auto">
|
||||
{providerSwitcher!.providers.map((p) => {
|
||||
const isSelected = providerSwitcher!.selectedProviderId === p.id;
|
||||
const defaultModel = p.defaultModel?.trim() ?? '';
|
||||
const hasModel = defaultModel.length > 0;
|
||||
// Rows without a defaultModel are inert — picking
|
||||
// one would save a binding with an empty model id
|
||||
// and produce a confusing model error at send time.
|
||||
// User has to set a defaultModel in Settings first.
|
||||
const disabled = !hasModel;
|
||||
const modelCaption = hasModel
|
||||
? defaultModel
|
||||
: t('ai.chat.noProviderModel');
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
title={disabled ? t('ai.chat.noProviderModel') : undefined}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
providerSwitcher!.onSelect(p.id, defaultModel);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 text-left transition-colors ${
|
||||
disabled
|
||||
? 'opacity-55 cursor-not-allowed'
|
||||
: 'hover:bg-muted/30 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<ProviderIconBadge provider={p} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-[12px] text-foreground/85">{p.name}</div>
|
||||
<div className={`truncate text-[10.5px] ${hasModel ? 'text-muted-foreground/70 font-mono' : 'text-muted-foreground/55 italic'}`}>
|
||||
{modelCaption}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <Check size={12} className="text-primary shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : modelPresets.map(preset => {
|
||||
const isSelected = preset.id === selectedBaseModelId;
|
||||
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
|
||||
return (
|
||||
@@ -743,33 +859,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{/* Permission mode chip — only for Catty Agent */}
|
||||
{permissionMode && onPermissionModeChange && (
|
||||
<>
|
||||
<button
|
||||
ref={permBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showPermPicker) {
|
||||
const rect = permBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('perm');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
|
||||
title={t('ai.safety.permissionMode')}
|
||||
aria-label="Permission mode"
|
||||
aria-expanded={showPermPicker}
|
||||
>
|
||||
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
|
||||
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
|
||||
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
|
||||
<span className="truncate max-w-[72px]">
|
||||
{permissionMode === 'observer' && t('ai.chat.permObserver')}
|
||||
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
|
||||
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
|
||||
</span>
|
||||
<ChevronDown size={9} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={permBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showPermPicker) {
|
||||
const rect = permBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('perm');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} shrink-0 cursor-pointer hover:bg-muted/24 transition-colors`}
|
||||
aria-label={t('ai.safety.permissionMode')}
|
||||
aria-expanded={showPermPicker}
|
||||
>
|
||||
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
|
||||
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
|
||||
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
|
||||
<span className="truncate max-w-[72px]">
|
||||
{permissionMode === 'observer' && t('ai.chat.permObserver')}
|
||||
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
|
||||
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
|
||||
</span>
|
||||
<ChevronDown size={9} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.safety.permissionMode')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showPermPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface ConversationExportProps {
|
||||
session: AISession | null;
|
||||
@@ -45,17 +46,21 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
|
||||
disabled={!hasMessages}
|
||||
title={t('ai.chat.exportConversation')}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
|
||||
disabled={!hasMessages}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.exportConversation')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
|
||||
61
components/ai/claudeConfigEnv.test.ts
Normal file
61
components/ai/claudeConfigEnv.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
splitClaudeEnv,
|
||||
buildClaudeEnv,
|
||||
parseEnvLines,
|
||||
serializeEnvLines,
|
||||
} from "../settings/tabs/ai/claudeConfigEnv";
|
||||
|
||||
test("splitClaudeEnv pulls out config dir and hides CLAUDE_CODE_EXECUTABLE", () => {
|
||||
const result = splitClaudeEnv({
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
assert.equal(result.configDir, "/cfg");
|
||||
assert.equal(result.envText, "ANTHROPIC_API_KEY=sk-x");
|
||||
});
|
||||
|
||||
test("splitClaudeEnv handles undefined env", () => {
|
||||
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", envText: "" });
|
||||
});
|
||||
|
||||
test("parseEnvLines parses KEY=VALUE, trims keys, keeps value as-is, skips blanks/comments", () => {
|
||||
assert.deepEqual(
|
||||
parseEnvLines("ANTHROPIC_API_KEY = sk-x\n# comment\n\nANTHROPIC_BASE_URL=https://h/?a=b"),
|
||||
{ ANTHROPIC_API_KEY: "sk-x", ANTHROPIC_BASE_URL: "https://h/?a=b" },
|
||||
);
|
||||
});
|
||||
|
||||
test("serializeEnvLines is the inverse for simple entries", () => {
|
||||
assert.equal(serializeEnvLines({ A: "1", B: "2" }), "A=1\nB=2");
|
||||
});
|
||||
|
||||
test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECUTABLE, drops empties", () => {
|
||||
const prev = { CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude", OLD: "x" };
|
||||
const next = buildClaudeEnv(prev, "/cfg", "ANTHROPIC_API_KEY=sk-x");
|
||||
assert.deepEqual(next, {
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildClaudeEnv omits config dir when blank and returns undefined when empty", () => {
|
||||
assert.equal(buildClaudeEnv(undefined, " ", ""), undefined);
|
||||
});
|
||||
|
||||
test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
|
||||
const next = buildClaudeEnv(
|
||||
{ CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude" },
|
||||
"/cfg",
|
||||
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nANTHROPIC_API_KEY=sk-x",
|
||||
);
|
||||
assert.deepEqual(next, {
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { isSdkStreamStateError } from '../../../infrastructure/ai/shared/streamStateErrors';
|
||||
import {
|
||||
extractProviderContinuationFromRawChunk,
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage,
|
||||
@@ -143,6 +144,7 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
agentEnv?: Record<string, string>,
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiUserSkillsGetStatus?: () => Promise<{
|
||||
@@ -646,9 +648,26 @@ export function useAIChatStreaming({
|
||||
// inside the tool's execute function via the approvalGate module.
|
||||
// The SDK may still emit this chunk type but we simply ignore it.
|
||||
case 'error': {
|
||||
const typedChunk = chunk as ErrorChunk;
|
||||
// Internal SDK reasoning/text state-machine errors (e.g. a
|
||||
// third-party Anthropic-compat backend like DeepSeek's
|
||||
// `-v4-flash` streaming thinking deltas without first emitting
|
||||
// the `reasoning-start` content-block signal) leak through
|
||||
// fullStream once per orphan delta. They're not user-facing
|
||||
// errors — and worse, surfacing one assistant message per
|
||||
// event breaks tool_use/tool_result contiguity on the next
|
||||
// turn, which the Anthropic backend then rejects as
|
||||
// `messages.N: tool_use ids were found without tool_result
|
||||
// blocks immediately after`. Filter them out at the chunk
|
||||
// boundary: drop the placeholder assistant message and keep
|
||||
// accepting subsequent chunks, so the rest of the stream
|
||||
// (real text, tool calls, the genuine `finish`) lands intact.
|
||||
if (isSdkStreamStateError(typedChunk.error)) {
|
||||
console.warn('[Catty] suppressed SDK stream state error:', typedChunk.error);
|
||||
break;
|
||||
}
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ErrorChunk;
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
|
||||
@@ -68,6 +68,21 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
|
||||
assert.equal(state.defaultAgentId, 'custom-agent');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores the system Claude executable for ACP runs', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'claude',
|
||||
{ path: '/opt/homebrew/bin/claude', version: '2.1.145 (Claude Code)', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/claude');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState does not remove user-created matching agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
|
||||
90
components/ai/streamdownCodeHighlighter.test.ts
Normal file
90
components/ai/streamdownCodeHighlighter.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type {
|
||||
CodeHighlighterPlugin,
|
||||
HighlightOptions,
|
||||
HighlightResult,
|
||||
} from 'streamdown';
|
||||
import {
|
||||
createPlainCodeHighlightResult,
|
||||
createSafeCodeHighlighter,
|
||||
resolveSupportedCodeLanguage,
|
||||
} from '../ai-elements/streamdownCodeHighlighter';
|
||||
|
||||
const createFakeHighlighter = (
|
||||
supportedLanguages: string[],
|
||||
highlightImpl?: CodeHighlighterPlugin['highlight'],
|
||||
): CodeHighlighterPlugin => ({
|
||||
name: 'shiki',
|
||||
type: 'code-highlighter',
|
||||
getSupportedLanguages: () => supportedLanguages as ReturnType<CodeHighlighterPlugin['getSupportedLanguages']>,
|
||||
getThemes: () => ['github-light', 'github-dark'],
|
||||
supportsLanguage: (language) => supportedLanguages.includes(language),
|
||||
highlight: highlightImpl ?? ((options: HighlightOptions): HighlightResult => ({
|
||||
tokens: [[{ content: options.language, offset: 0 }]],
|
||||
})),
|
||||
});
|
||||
|
||||
test('maps generic conf fences to ini for Streamdown highlighting', () => {
|
||||
const highlighter = createFakeHighlighter(['ini']);
|
||||
|
||||
assert.equal(resolveSupportedCodeLanguage(highlighter, 'conf'), 'ini');
|
||||
assert.equal(resolveSupportedCodeLanguage(highlighter, ' config '), 'ini');
|
||||
});
|
||||
|
||||
test('falls back to plain tokens for unsupported languages', () => {
|
||||
const highlighter = createSafeCodeHighlighter(
|
||||
createFakeHighlighter([], () => {
|
||||
throw new Error('delegate should not be called for unsupported languages');
|
||||
}),
|
||||
);
|
||||
|
||||
const result = highlighter.highlight({
|
||||
code: '*.* action(type="omfwd"\n Target="10.185.3.1")\n',
|
||||
language: 'conf',
|
||||
themes: ['github-light', 'github-dark'],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result?.tokens.map((line) => line.map((token) => token.content).join('')),
|
||||
['*.* action(type="omfwd"', ' Target="10.185.3.1")'],
|
||||
);
|
||||
});
|
||||
|
||||
test('uses supported aliases when highlighting generic config blocks', () => {
|
||||
let receivedLanguage: string | null = null;
|
||||
const highlighter = createSafeCodeHighlighter(
|
||||
createFakeHighlighter(['ini'], (options: HighlightOptions): HighlightResult => {
|
||||
receivedLanguage = options.language;
|
||||
return createPlainCodeHighlightResult(options.code);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = highlighter.highlight({
|
||||
code: '*.* action(type="omfwd")',
|
||||
language: 'conf',
|
||||
themes: ['github-light', 'github-dark'],
|
||||
});
|
||||
|
||||
assert.equal(receivedLanguage, 'ini');
|
||||
assert.equal(result?.tokens[0][0].content, '*.* action(type="omfwd")');
|
||||
});
|
||||
|
||||
test('treats text fences as plain code without calling the delegate', () => {
|
||||
const highlighter = createSafeCodeHighlighter(
|
||||
createFakeHighlighter(['ini'], () => {
|
||||
throw new Error('delegate should not be called for text fences');
|
||||
}),
|
||||
);
|
||||
|
||||
const result = highlighter.highlight({
|
||||
code: 'hello\nworld',
|
||||
language: 'text',
|
||||
themes: ['github-light', 'github-dark'],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result?.tokens.map((line) => line[0].content),
|
||||
['hello', 'world'],
|
||||
);
|
||||
});
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
isTextEditorReadOnly,
|
||||
TextEditorPromoteButton,
|
||||
} from "./TextEditorPane.tsx";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
|
||||
const wrap = (child: React.ReactElement) =>
|
||||
React.createElement(TooltipProvider, null, child);
|
||||
|
||||
test("disables promoting a modal editor to a tab while a save is running", () => {
|
||||
assert.equal(canPromoteTextEditor({ saving: true }), false);
|
||||
@@ -18,18 +22,22 @@ test("disables promoting a modal editor to a tab while a save is running", () =>
|
||||
|
||||
test("renders the promote button disabled while a save is running", () => {
|
||||
const savingMarkup = renderToStaticMarkup(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: true,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
wrap(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: true,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const idleMarkup = renderToStaticMarkup(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: false,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
wrap(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: false,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(savingMarkup, /disabled=""/);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models
|
||||
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
@@ -186,16 +187,20 @@ export const TextEditorPromoteButton: React.FC<{
|
||||
onPromoteToTab: () => void;
|
||||
title: string;
|
||||
}> = ({ saving, onPromoteToTab, title }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onPromoteToTab}
|
||||
disabled={!canPromoteTextEditor({ saving })}
|
||||
title={title}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onPromoteToTab}
|
||||
disabled={!canPromoteTextEditor({ saving })}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{title}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
@@ -479,34 +484,47 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
{fileName}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span className="text-xs text-muted-foreground truncate" title={subtitle}>
|
||||
{subtitle}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs text-muted-foreground truncate cursor-default">
|
||||
{subtitle}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{subtitle}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{saveError && <span className="text-xs text-destructive truncate">{saveError}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
title={t('common.search')}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.search')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={wordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={wordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sftp.editor.wordWrap')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
|
||||
273
components/host-details/AlgorithmOverridesPanel.tsx
Normal file
273
components/host-details/AlgorithmOverridesPanel.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
effectiveDefaultAlgorithms,
|
||||
SSH_ALGORITHM_CATEGORIES,
|
||||
SSHAlgorithmCategory,
|
||||
SUPPORTED_ALGORITHMS_BY_CATEGORY,
|
||||
} from "../../domain/sshAlgorithmList";
|
||||
import type { HostAlgorithmOverrides } from "../../domain/models";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
interface Props {
|
||||
value: HostAlgorithmOverrides | undefined;
|
||||
onChange: (next: HostAlgorithmOverrides | undefined) => void;
|
||||
/**
|
||||
* The host's current `legacyAlgorithms` value, used to seed the very
|
||||
* first customization in each category with the *effective* default
|
||||
* list (modern-only vs modern+legacy) rather than the full SUPPORTED
|
||||
* set. Without this, unchecking a single algorithm in modern mode
|
||||
* would silently start advertising CBC / arcfour / MD5 algorithms.
|
||||
*/
|
||||
legacyEnabled: boolean;
|
||||
/**
|
||||
* Algorithm overrides this host would inherit from its group when its
|
||||
* own field is unset. Used purely for display: an `undefined` value
|
||||
* here means the host can freely use NetCatty defaults by resetting
|
||||
* a category; a populated value means the host would inherit those
|
||||
* lists, and resetting locally falls back to them — the panel
|
||||
* surfaces that so the user knows the local Reset button doesn't
|
||||
* jump them to NetCatty's defaults in that case.
|
||||
*/
|
||||
inheritedFromGroup?: HostAlgorithmOverrides;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL_KEY: Record<SSHAlgorithmCategory, string> = {
|
||||
kex: "hostDetails.algorithms.category.kex",
|
||||
cipher: "hostDetails.algorithms.category.cipher",
|
||||
hmac: "hostDetails.algorithms.category.hmac",
|
||||
serverHostKey: "hostDetails.algorithms.category.serverHostKey",
|
||||
compress: "hostDetails.algorithms.category.compress",
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-category SSH algorithm override editor.
|
||||
*
|
||||
* When a category's array is `undefined`, that category uses NetCatty's
|
||||
* negotiated default list. When it's a non-empty array, that array fully
|
||||
* replaces the offered list for the category.
|
||||
*
|
||||
* Picking zero algorithms in a category is equivalent to "use default" —
|
||||
* an empty array would make ssh2 fail negotiation, so we normalize it
|
||||
* back to `undefined` on save.
|
||||
*/
|
||||
export const AlgorithmOverridesPanel: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
legacyEnabled,
|
||||
inheritedFromGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const effectiveDefault = useMemo(
|
||||
() => effectiveDefaultAlgorithms(legacyEnabled),
|
||||
[legacyEnabled],
|
||||
);
|
||||
// What the runtime *actually* inherits from the group for display
|
||||
// purposes. `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing boundary: once the host carries any local
|
||||
// `algorithms` object the group's overrides stop being applied — even
|
||||
// for categories the host didn't override. So as soon as `value` is
|
||||
// non-undefined we must stop *displaying* inherited categories,
|
||||
// otherwise the UI lies about what will be negotiated.
|
||||
//
|
||||
// The write-side (`updateCategory` / `toggleAlgorithm` / Reset) still
|
||||
// consults the unconditional `inheritedFromGroup` so that the first
|
||||
// user edit on an unset host carries the inherited categories into
|
||||
// the host object, preventing the runtime's silent widening that
|
||||
// motivated those write-side fixes.
|
||||
const inheritedForDisplay = useMemo(
|
||||
() => (value === undefined ? inheritedFromGroup : undefined),
|
||||
[value, inheritedFromGroup],
|
||||
);
|
||||
const inheritedCategories = useMemo(() => {
|
||||
if (!inheritedForDisplay) return [] as SSHAlgorithmCategory[];
|
||||
return SSH_ALGORITHM_CATEGORIES.filter((category) => {
|
||||
const list = inheritedForDisplay[category];
|
||||
return Array.isArray(list) && list.length > 0;
|
||||
});
|
||||
}, [inheritedForDisplay]);
|
||||
|
||||
const updateCategory = useCallback(
|
||||
(category: SSHAlgorithmCategory, selected: string[]) => {
|
||||
// Start from the inherited group overrides so that touching one
|
||||
// category doesn't silently drop inheritance for the others.
|
||||
// `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing inherit boundary: once the host carries any
|
||||
// explicit object, the host's `algorithms` shadows the group's
|
||||
// `algorithms` entirely. If the user customized cipher locally
|
||||
// and the group restricted serverHostKey, simply storing
|
||||
// `{ cipher: [...] }` on the host would lose the group's
|
||||
// serverHostKey restriction. Persisting the inherited categories
|
||||
// alongside keeps the effective offer intact.
|
||||
const base: HostAlgorithmOverrides = inheritedFromGroup
|
||||
? { ...inheritedFromGroup }
|
||||
: {};
|
||||
const next: HostAlgorithmOverrides = { ...base, ...(value ?? {}) };
|
||||
if (selected.length === 0) {
|
||||
delete next[category];
|
||||
} else {
|
||||
next[category] = selected;
|
||||
}
|
||||
const hasAny = Object.values(next).some((arr) => Array.isArray(arr) && arr.length > 0);
|
||||
onChange(hasAny ? next : undefined);
|
||||
},
|
||||
[value, onChange, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const toggleAlgorithm = useCallback(
|
||||
(category: SSHAlgorithmCategory, algo: string) => {
|
||||
const current = value?.[category];
|
||||
if (!current) {
|
||||
// First click in this category — seed with the *effective* offer
|
||||
// for this category. If the group has set a list for this
|
||||
// category, use that (so customizing one entry doesn't lose the
|
||||
// group's narrowing). Otherwise seed from NetCatty's effective
|
||||
// default, which already accounts for legacy mode. Seeding from
|
||||
// SUPPORTED_ALGORITHMS_BY_CATEGORY would silently introduce
|
||||
// legacy algorithms (CBC, arcfour, MD5) into the offered list.
|
||||
const baseline = inheritedFromGroup?.[category] ?? effectiveDefault[category];
|
||||
if (baseline.includes(algo)) {
|
||||
updateCategory(category, baseline.filter((a) => a !== algo));
|
||||
} else {
|
||||
// The user clicked an algorithm not in the baseline — they
|
||||
// want to opt INTO it. Start the override with the baseline
|
||||
// plus this extra entry.
|
||||
updateCategory(category, [...baseline, algo]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current.includes(algo)) {
|
||||
updateCategory(category, current.filter((a) => a !== algo));
|
||||
} else {
|
||||
updateCategory(category, [...current, algo]);
|
||||
}
|
||||
},
|
||||
[value, updateCategory, effectiveDefault, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const resetCategory = useCallback(
|
||||
(category: SSHAlgorithmCategory) => {
|
||||
const inherited = inheritedFromGroup?.[category];
|
||||
const next: HostAlgorithmOverrides = { ...(value ?? {}) };
|
||||
if (Array.isArray(inherited) && inherited.length > 0) {
|
||||
// The group has an override for this category. Just deleting
|
||||
// `next[category]` would *widen* the effective offer: because
|
||||
// `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing inherit boundary, once any other category
|
||||
// remains on the host the group's `algorithms` object stops
|
||||
// being inherited as a whole, and the missing category falls
|
||||
// back to NetCatty defaults — not the group's narrower list.
|
||||
// Persist the inherited list verbatim instead, so Reset means
|
||||
// "use what this host would otherwise inherit" rather than
|
||||
// "silently switch to NetCatty defaults".
|
||||
next[category] = inherited.slice();
|
||||
} else {
|
||||
delete next[category];
|
||||
}
|
||||
const hasAny = Object.values(next).some((arr) => Array.isArray(arr) && arr.length > 0);
|
||||
onChange(hasAny ? next : undefined);
|
||||
},
|
||||
[value, onChange, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const isCustomized = useCallback(
|
||||
(category: SSHAlgorithmCategory) => {
|
||||
const local = value?.[category];
|
||||
if (!Array.isArray(local) || local.length === 0) return false;
|
||||
// If the host's list is identical (order + contents) to the
|
||||
// inherited list, the user hasn't really customized it — they
|
||||
// either reset to the upstream value or never touched it directly.
|
||||
// Suppressing the "customized" badge in that case keeps the UI
|
||||
// honest about what the user actually changed.
|
||||
const inherited = inheritedFromGroup?.[category];
|
||||
if (Array.isArray(inherited)
|
||||
&& inherited.length === local.length
|
||||
&& inherited.every((a, i) => a === local[i])) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[value, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const isChecked = useCallback(
|
||||
(category: SSHAlgorithmCategory, algo: string) => {
|
||||
const current = value?.[category];
|
||||
if (current) return current.includes(algo);
|
||||
// No host-local override for this category: reflect what the host
|
||||
// would actually advertise. Uses `inheritedForDisplay` (the same
|
||||
// gating the inherited notice uses) so that a host that already
|
||||
// has any local override stops pretending its empty categories
|
||||
// still come from the group — `applyGroupDefaults` won't apply
|
||||
// them, and the runtime falls back to NetCatty defaults.
|
||||
const baseline = inheritedForDisplay?.[category] ?? effectiveDefault[category];
|
||||
return baseline.includes(algo);
|
||||
},
|
||||
[value, effectiveDefault, inheritedForDisplay],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.algorithms.advanced.desc")}
|
||||
</p>
|
||||
{inheritedCategories.length > 0 && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 break-words">
|
||||
{t("hostDetails.algorithms.inheritedNotice")
|
||||
.replace(
|
||||
"{categories}",
|
||||
inheritedCategories.map((c) => t(CATEGORY_LABEL_KEY[c])).join(", "),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{SSH_ALGORITHM_CATEGORIES.map((category) => {
|
||||
const supported = SUPPORTED_ALGORITHMS_BY_CATEGORY[category];
|
||||
const customized = isCustomized(category);
|
||||
return (
|
||||
<Card key={category} className="p-2 space-y-1.5 bg-background border-border/60">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium">
|
||||
{t(CATEGORY_LABEL_KEY[category])}
|
||||
{customized && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
{t("hostDetails.algorithms.customized")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{customized && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
onClick={() => resetCategory(category)}
|
||||
>
|
||||
{t("hostDetails.algorithms.reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{supported.map((algo) => (
|
||||
<label
|
||||
key={algo}
|
||||
className="flex items-center gap-2 text-[11px] cursor-pointer select-none hover:bg-accent/40 rounded px-1 py-0.5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={isChecked(category, algo)}
|
||||
onChange={() => toggleAlgorithm(category, algo)}
|
||||
/>
|
||||
<span className="font-mono truncate" title={algo}>{algo}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { Combobox } from '../ui/combobox';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Popover,PopoverContent,PopoverTrigger } from '../ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface IdentityPanelProps {
|
||||
draftIdentity: Partial<Identity>;
|
||||
@@ -129,15 +130,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{selectedKey?.label || t('hostDetails.credential.missing')}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={clearSelectedKey}
|
||||
title={t('common.clear')}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={clearSelectedKey}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.clear')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -202,15 +207,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
title={t('common.cancel')}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.cancel')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -230,15 +239,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
|
||||
icon={<Shield size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
title={t('common.cancel')}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.cancel')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
// Utilities and types
|
||||
export {
|
||||
copyToClipboard,detectKeyType,generateMockKeyPair,getKeyIcon,
|
||||
getKeyTypeDisplay,isMacOS,type FilterTab,type PanelMode
|
||||
isMacOS,type FilterTab,type PanelMode
|
||||
} from './utils';
|
||||
|
||||
// Card components
|
||||
|
||||
@@ -7,33 +7,6 @@ import React from 'react';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { KeyType, SSHKey } from '../../types';
|
||||
|
||||
/**
|
||||
* Generate mock key pair (for fallback when Electron backend is unavailable)
|
||||
*/
|
||||
export const generateMockKeyPair = (type: KeyType, label: string, keySize?: number): { privateKey: string; publicKey: string } => {
|
||||
const typeMap: Record<KeyType, string> = {
|
||||
'ED25519': 'ed25519',
|
||||
'ECDSA': `ecdsa-sha2-nistp${keySize || 256}`,
|
||||
'RSA': 'rsa',
|
||||
};
|
||||
|
||||
const randomId = crypto.randomUUID().replace(/-/g, '').substring(0, 32);
|
||||
|
||||
// Generate size-appropriate random data for more realistic keys
|
||||
const keyLength = type === 'RSA' ? (keySize || 4096) / 8 : 32;
|
||||
const randomData = Array.from(crypto.getRandomValues(new Uint8Array(keyLength)))
|
||||
.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
const privateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACB${randomId}AAAEC${randomData.substring(0, 64)}
|
||||
-----END OPENSSH PRIVATE KEY-----`;
|
||||
|
||||
const publicKey = `ssh-${typeMap[type]} AAAAC3NzaC1lZDI1NTE5AAAAI${randomId.substring(0, 20)} ${label}@netcatty`;
|
||||
|
||||
return { privateKey, publicKey };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get icon element for key source
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TrafficDiagram } from '../TrafficDiagram';
|
||||
import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { getTypeLabel } from './utils';
|
||||
@@ -183,14 +184,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground/80 flex items-center gap-1 px-2 py-1 rounded hover:bg-foreground/5 transition-colors"
|
||||
onClick={onOpenWizard}
|
||||
title={t('pf.form.openWizardTitle')}
|
||||
>
|
||||
<Zap size={12} />
|
||||
{t('pf.form.openWizard')}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground/80 flex items-center gap-1 px-2 py-1 rounded hover:bg-foreground/5 transition-colors"
|
||||
onClick={onOpenWizard}
|
||||
>
|
||||
<Zap size={12} />
|
||||
{t('pf.form.openWizard')}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('pf.form.openWizardTitle')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
|
||||
@@ -68,13 +68,26 @@ export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold truncate">{rule.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full flex-shrink-0",
|
||||
getStatusColor(rule.status)
|
||||
)}
|
||||
title={rule.status === 'error' && rule.error ? rule.error : undefined}
|
||||
/>
|
||||
{rule.status === 'error' && rule.error ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full flex-shrink-0 cursor-default",
|
||||
getStatusColor(rule.status)
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{rule.error}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full flex-shrink-0",
|
||||
getStatusColor(rule.status)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
/**
|
||||
* Port Forwarding components module
|
||||
* Re-exports all port forwarding sub-components
|
||||
* Re-exports the entries consumed by the top-level port forwarding view.
|
||||
*/
|
||||
|
||||
export {
|
||||
TYPE_DESCRIPTION_KEYS,
|
||||
TYPE_LABEL_KEYS,
|
||||
TYPE_MENU_LABEL_KEYS,
|
||||
TYPE_ICONS,
|
||||
generateRuleLabel,
|
||||
getStatusColor,
|
||||
getTypeColor,
|
||||
getTypeDescription,
|
||||
getTypeLabel,
|
||||
getTypeMenuLabel,
|
||||
} from './utils';
|
||||
|
||||
export { RuleCard } from './RuleCard';
|
||||
export type { RuleCardProps,ViewMode } from './RuleCard';
|
||||
|
||||
export { WizardContent } from './WizardContent';
|
||||
export type { WizardContentProps,WizardStep } from './WizardContent';
|
||||
|
||||
export { EditPanel } from './EditPanel';
|
||||
export type { EditPanelProps } from './EditPanel';
|
||||
|
||||
export { NewFormPanel } from './NewFormPanel';
|
||||
export type { NewFormPanelProps } from './NewFormPanel';
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
/**
|
||||
* Port Forwarding utilities and constants
|
||||
*/
|
||||
import { Globe,Server,Shuffle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { PortForwardingType } from '../../domain/models';
|
||||
|
||||
export const TYPE_LABEL_KEYS: Record<PortForwardingType, string> = {
|
||||
const TYPE_LABEL_KEYS: Record<PortForwardingType, string> = {
|
||||
local: 'pf.type.local',
|
||||
remote: 'pf.type.remote',
|
||||
dynamic: 'pf.type.dynamic',
|
||||
};
|
||||
|
||||
export const TYPE_MENU_LABEL_KEYS: Record<PortForwardingType, string> = {
|
||||
const TYPE_MENU_LABEL_KEYS: Record<PortForwardingType, string> = {
|
||||
local: 'pf.type.menu.local',
|
||||
remote: 'pf.type.menu.remote',
|
||||
dynamic: 'pf.type.menu.dynamic',
|
||||
};
|
||||
|
||||
export const TYPE_DESCRIPTION_KEYS: Record<PortForwardingType, string> = {
|
||||
const TYPE_DESCRIPTION_KEYS: Record<PortForwardingType, string> = {
|
||||
local: 'pf.type.local.desc',
|
||||
remote: 'pf.type.remote.desc',
|
||||
dynamic: 'pf.type.dynamic.desc',
|
||||
@@ -44,12 +42,6 @@ export function getTypeDescription(
|
||||
return t(TYPE_DESCRIPTION_KEYS[type]);
|
||||
}
|
||||
|
||||
export const TYPE_ICONS: Record<PortForwardingType, React.ReactNode> = {
|
||||
local: <Globe size={16} />,
|
||||
remote: <Server size={16} />,
|
||||
dynamic: <Shuffle size={16} />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get status color class for a rule
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,8 @@ interface ThemeSelectModalProps {
|
||||
onClose: () => void;
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
filterType?: 'dark' | 'light';
|
||||
showAutoOption?: boolean;
|
||||
}
|
||||
|
||||
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
@@ -22,6 +24,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
onClose,
|
||||
selectedThemeId,
|
||||
onSelect,
|
||||
filterType,
|
||||
showAutoOption,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -85,6 +89,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
<ThemeList
|
||||
selectedThemeId={selectedThemeId}
|
||||
onSelect={handleThemeSelect}
|
||||
filterType={filterType}
|
||||
showAutoOption={showAutoOption}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
buildManagedAgentState,
|
||||
getInitialManagedAgentPaths,
|
||||
} from "./ai/managedAgentState";
|
||||
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
@@ -125,6 +126,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [claudeCustomPath, setClaudeCustomPath] = useState("");
|
||||
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
|
||||
|
||||
const claudeManagedEnv = useMemo(
|
||||
() => externalAgents.find((a) => a.id === "discovered_claude")?.env,
|
||||
[externalAgents],
|
||||
);
|
||||
const { configDir: claudeConfigDir, envText: claudeEnvText } = useMemo(
|
||||
() => splitClaudeEnv(claudeManagedEnv),
|
||||
[claudeManagedEnv],
|
||||
);
|
||||
|
||||
const updateClaudeEnv = useCallback(
|
||||
(nextConfigDir: string, nextEnvText: string) => {
|
||||
setExternalAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === "discovered_claude"
|
||||
? { ...a, env: buildClaudeEnv(a.env, nextConfigDir, nextEnvText) }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setExternalAgents],
|
||||
);
|
||||
|
||||
const initialManagedPathsRef = useRef<{
|
||||
codex: string;
|
||||
claude: string;
|
||||
@@ -542,6 +566,10 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
customPath={claudeCustomPath}
|
||||
onCustomPathChange={setClaudeCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("claude")}
|
||||
configDir={claudeConfigDir}
|
||||
onConfigDirChange={(v) => updateClaudeEnv(v, claudeEnvText)}
|
||||
envText={claudeEnvText}
|
||||
onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
|
||||
export default function SettingsAppearanceTab(props: {
|
||||
theme: "dark" | "light" | "system";
|
||||
@@ -122,20 +123,23 @@ export default function SettingsAppearanceTab(props: {
|
||||
) => (
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
{options.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => onChange(preset.id)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
|
||||
value === preset.id
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
style={getHslStyle(preset.tokens.background)}
|
||||
title={preset.name}
|
||||
>
|
||||
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
|
||||
</button>
|
||||
<Tooltip key={preset.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onChange(preset.id)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
|
||||
value === preset.id
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
style={getHslStyle(preset.tokens.background)}
|
||||
>
|
||||
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{preset.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -212,42 +216,49 @@ export default function SettingsAppearanceTab(props: {
|
||||
<div className="text-sm font-medium">{t("settings.appearance.accentColor.custom")}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ACCENT_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
onClick={() => setCustomAccent(c.value)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
|
||||
customAccent === c.value
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
style={getHslStyle(c.value)}
|
||||
title={c.name}
|
||||
>
|
||||
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
|
||||
</button>
|
||||
<Tooltip key={c.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setCustomAccent(c.value)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
|
||||
customAccent === c.value
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
style={getHslStyle(c.value)}
|
||||
>
|
||||
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{c.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<label
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
|
||||
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
|
||||
!ACCENT_COLORS.some((c) => c.value === customAccent)
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
title={t("settings.appearance.customColor")}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
className="sr-only"
|
||||
onChange={(e) => setCustomAccent(hexToHsl(e.target.value))}
|
||||
/>
|
||||
{!ACCENT_COLORS.some((c) => c.value === customAccent) ? (
|
||||
<Check className="text-white drop-shadow-md" size={10} />
|
||||
) : (
|
||||
<Palette size={12} className="text-white drop-shadow-md" />
|
||||
)}
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
|
||||
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
|
||||
!ACCENT_COLORS.some((c) => c.value === customAccent)
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
className="sr-only"
|
||||
onChange={(e) => setCustomAccent(hexToHsl(e.target.value))}
|
||||
/>
|
||||
{!ACCENT_COLORS.some((c) => c.value === customAccent) ? (
|
||||
<Check className="text-white drop-shadow-md" size={10} />
|
||||
) : (
|
||||
<Palette size={12} className="text-white drop-shadow-md" />
|
||||
)}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.appearance.customColor")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Label } from "../../ui/label";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import { SectionHeader, SettingsTabContent } from "../settings-ui";
|
||||
|
||||
const getOpenerLabel = (
|
||||
@@ -527,31 +528,44 @@ export default function SettingsFileAssociationsTab() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{openerType === 'system-app' && systemApp ? (
|
||||
<span title={systemApp.path}>{systemApp.name}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default">{systemApp.name}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{systemApp.path}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
getOpenerLabel(openerType, systemApp, t)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(extension)}
|
||||
disabled={editingExtension === extension}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemove(extension)}
|
||||
title={t('settings.sftpFileAssociations.remove')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(extension)}
|
||||
disabled={editingExtension === extension}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.edit')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemove(extension)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('settings.sftpFileAssociations.remove')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function SettingsShortcutsTab(props: {
|
||||
<button
|
||||
onClick={() => updateKeyBinding?.(binding.id, scheme, "Disabled")}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.shortcuts.setDisabled")}
|
||||
aria-label={t("settings.shortcuts.setDisabled")}
|
||||
>
|
||||
<Ban size={12} />
|
||||
</button>
|
||||
@@ -238,7 +238,7 @@ export default function SettingsShortcutsTab(props: {
|
||||
<button
|
||||
onClick={() => resetKeyBinding?.(binding.id, scheme)}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title="Reset to default"
|
||||
aria-label={t("settings.shortcuts.resetToDefault")}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
buildLocalVaultPayload,
|
||||
buildSyncPayload,
|
||||
applySyncPayload,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
} from "../../../application/syncPayload";
|
||||
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
|
||||
import type { SyncableVaultData } from "../../../application/syncPayload";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
|
||||
import { CloudSyncSettings } from "../../CloudSyncSettings";
|
||||
import { SettingsTabContent } from "../settings-ui";
|
||||
@@ -35,28 +34,7 @@ export default function SettingsSyncTab(props: {
|
||||
const { t } = useI18n();
|
||||
|
||||
const getEffectivePortForwardingRules = useCallback((): PortForwardingRule[] => {
|
||||
// If hook state is empty but localStorage has data, the async store
|
||||
// initialization hasn't finished yet. Read from localStorage directly
|
||||
// to avoid uploading empty arrays and overwriting the remote snapshot.
|
||||
let effectiveRules = portForwardingRules;
|
||||
if (effectiveRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
// Strip transient per-device fields (status, error, lastUsedAt)
|
||||
// that setGlobalRules persists to localStorage but shouldn't be
|
||||
// included in the cloud sync snapshot.
|
||||
effectiveRules = stored.map(({ status: _status, error: _error, ...rest }) => ({
|
||||
...rest,
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveRules;
|
||||
return getEffectivePortForwardingRulesForSync(portForwardingRules) ?? [];
|
||||
}, [portForwardingRules]);
|
||||
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { UpdateState } from '../../../application/state/useUpdateCheck';
|
||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import { Toggle, Select, SettingRow } from "../settings-ui";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
@@ -637,9 +638,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
|
||||
const text = parts.join(' ');
|
||||
return text ? (
|
||||
<div className="text-muted-foreground truncate" title={text}>
|
||||
{text}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-muted-foreground truncate cursor-default">
|
||||
{text}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{text}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
})()}
|
||||
{entry.stack && (
|
||||
@@ -678,14 +684,18 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
<Trash2 size={14} />
|
||||
{t("settings.system.crashLogs.clear")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenCrashLogsDir}
|
||||
title={t("settings.system.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenCrashLogsDir}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{crashLogClearResult && (
|
||||
@@ -716,16 +726,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
{isLoading ? "..." : (tempDirInfo?.path ?? "-")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={handleOpenTempDir}
|
||||
disabled={!tempDirInfo?.path}
|
||||
title={t("settings.system.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={handleOpenTempDir}
|
||||
disabled={!tempDirInfo?.path}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -823,15 +837,19 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
{t("settings.sessionLogs.browse")}
|
||||
</Button>
|
||||
{sessionLogsDir && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenSessionLogsDir}
|
||||
className="shrink-0"
|
||||
title={t("settings.sessionLogs.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenSessionLogsDir}
|
||||
className="shrink-0"
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.sessionLogs.openFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -902,13 +920,17 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.globalHotkey.reset")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
@@ -19,12 +19,15 @@ import { Button } from "../../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { Textarea } from "../../ui/textarea";
|
||||
import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { ThemeSelectModal } from "../ThemeSelectModal";
|
||||
import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
import { TerminalCjkFontSelect } from "../TerminalCjkFontSelect";
|
||||
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
|
||||
import type { TerminalTheme } from "../../../domain/models";
|
||||
import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../domain/terminalAppearance";
|
||||
|
||||
// Keyword highlight rules editor for global settings
|
||||
const DEFAULT_NEW_RULE_COLOR = '#F87171';
|
||||
@@ -33,21 +36,25 @@ const AddCustomRuleDialog: React.FC<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editRule?: KeywordHighlightRule | null;
|
||||
isBuiltIn?: boolean;
|
||||
onAdd: (rule: KeywordHighlightRule) => void;
|
||||
}> = ({ open, onOpenChange, editRule, onAdd }) => {
|
||||
}> = ({ open, onOpenChange, editRule, isBuiltIn = false, onAdd }) => {
|
||||
const { t } = useI18n();
|
||||
const [label, setLabel] = useState('');
|
||||
const [pattern, setPattern] = useState('');
|
||||
// Multi-line text: one regex pattern per line. Built-in rules typically
|
||||
// ship multiple patterns (e.g. several spellings of "error"), and the user
|
||||
// is allowed to add as many as they like.
|
||||
const [patternsText, setPatternsText] = useState('');
|
||||
const [color, setColor] = useState(DEFAULT_NEW_RULE_COLOR);
|
||||
const [patternError, setPatternError] = useState<string | null>(null);
|
||||
|
||||
const reset = () => { setLabel(''); setPattern(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
|
||||
const reset = () => { setLabel(''); setPatternsText(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (open && editRule) {
|
||||
setLabel(editRule.label);
|
||||
setPattern(editRule.patterns[0] || '');
|
||||
setPatternsText(editRule.patterns.join('\n'));
|
||||
setColor(editRule.color);
|
||||
setPatternError(null);
|
||||
} else if (!open) {
|
||||
@@ -56,25 +63,43 @@ const AddCustomRuleDialog: React.FC<{
|
||||
}, [open, editRule]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!label.trim() || !pattern.trim()) return;
|
||||
try { new RegExp(pattern, 'gi'); } catch {
|
||||
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
|
||||
return;
|
||||
if (!label.trim()) return;
|
||||
const patterns = patternsText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
if (patterns.length === 0) return;
|
||||
for (const p of patterns) {
|
||||
try { new RegExp(p, 'gi'); } catch {
|
||||
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// When editing, replace only the first pattern and keep any additional ones
|
||||
const patterns = editRule
|
||||
? [pattern, ...editRule.patterns.slice(1)]
|
||||
: [pattern];
|
||||
onAdd({ id: editRule?.id ?? crypto.randomUUID(), label: label.trim(), patterns, color, enabled: editRule?.enabled ?? true });
|
||||
onAdd({
|
||||
id: editRule?.id ?? crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
patterns,
|
||||
color,
|
||||
enabled: editRule?.enabled ?? true,
|
||||
// Editing a built-in rule flips it into "user-customized" mode so the
|
||||
// normalizer keeps the user's patterns across restarts.
|
||||
customized: isBuiltIn ? true : editRule?.customized,
|
||||
});
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const dialogTitleKey = editRule
|
||||
? (isBuiltIn
|
||||
? 'settings.terminal.keywordHighlight.editBuiltIn'
|
||||
: 'settings.terminal.keywordHighlight.editCustom')
|
||||
: 'settings.terminal.keywordHighlight.addCustom';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editRule ? t('settings.terminal.keywordHighlight.editCustom') : t('settings.terminal.keywordHighlight.addCustom')}</DialogTitle>
|
||||
<DialogTitle>{t(dialogTitleKey)}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
@@ -94,16 +119,19 @@ const AddCustomRuleDialog: React.FC<{
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('settings.terminal.keywordHighlight.patternField')}</Label>
|
||||
<Input
|
||||
<Textarea
|
||||
placeholder={t('settings.terminal.keywordHighlight.patternPlaceholder')}
|
||||
value={pattern}
|
||||
onChange={(e) => { setPattern(e.target.value); if (patternError) setPatternError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
className={cn("font-mono", patternError && "border-destructive")}
|
||||
value={patternsText}
|
||||
onChange={(e) => { setPatternsText(e.target.value); if (patternError) setPatternError(null); }}
|
||||
rows={Math.max(3, Math.min(10, patternsText.split('\n').length + 1))}
|
||||
className={cn("font-mono text-xs", patternError && "border-destructive")}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('settings.terminal.keywordHighlight.patternHint')}
|
||||
</p>
|
||||
{patternError && <div className="text-xs text-destructive">{patternError}</div>}
|
||||
</div>
|
||||
{label.trim() && pattern.trim() && !patternError && (
|
||||
{label.trim() && patternsText.trim() && !patternError && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">{t('settings.terminal.keywordHighlight.preview')}:</span>
|
||||
<span className="text-sm font-medium" style={{ color }}>{label}</span>
|
||||
@@ -112,7 +140,7 @@ const AddCustomRuleDialog: React.FC<{
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!label.trim() || !pattern.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!label.trim() || !patternsText.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -132,26 +160,43 @@ const KeywordHighlightRulesEditor: React.FC<{
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{rules.map((rule) => {
|
||||
const custom = !isBuiltIn(rule.id);
|
||||
const builtIn = isBuiltIn(rule.id);
|
||||
const customized = builtIn && rule.customized;
|
||||
return (
|
||||
<div key={rule.id} className="flex items-center gap-2 group">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span className={cn("text-sm truncate", !rule.enabled && "text-muted-foreground line-through")} style={rule.enabled ? { color: rule.color } : undefined}>
|
||||
{rule.label}
|
||||
</span>
|
||||
{custom && (
|
||||
<>
|
||||
<Pencil
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
|
||||
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
|
||||
/>
|
||||
<Trash2
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
|
||||
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
|
||||
/>
|
||||
</>
|
||||
<Pencil
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
|
||||
/>
|
||||
{!builtIn && (
|
||||
<Trash2
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-destructive"
|
||||
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
|
||||
/>
|
||||
)}
|
||||
{customized && (
|
||||
<RotateCcw
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
aria-label={t('settings.terminal.keywordHighlight.resetBuiltIn')}
|
||||
onClick={() => {
|
||||
// Drop the user's customizations and restore the shipped
|
||||
// defaults for label/patterns. Color stays whatever the
|
||||
// user picked (color is the only built-in property they
|
||||
// can edit without flipping `customized`).
|
||||
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
|
||||
if (!def) return;
|
||||
onChange(rules.map((r) => r.id === rule.id
|
||||
? { ...def, color: r.color, enabled: r.enabled, customized: false }
|
||||
: r));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className="relative flex-shrink-0">
|
||||
@@ -185,14 +230,18 @@ const KeywordHighlightRulesEditor: React.FC<{
|
||||
size="sm"
|
||||
className="flex-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
// Restore every built-in rule back to shipped defaults
|
||||
// (label/patterns/color), drop customizations, and keep the user's
|
||||
// custom rules untouched.
|
||||
onChange(rules.map((rule) => {
|
||||
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
|
||||
return def ? { ...rule, color: def.color } : rule;
|
||||
if (!def) return rule;
|
||||
return { ...def, enabled: rule.enabled, customized: false };
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t("settings.terminal.keywordHighlight.resetColors")}
|
||||
{t("settings.terminal.keywordHighlight.resetDefaults")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -200,6 +249,7 @@ const KeywordHighlightRulesEditor: React.FC<{
|
||||
open={addDialogOpen}
|
||||
onOpenChange={(v) => { setAddDialogOpen(v); if (!v) setEditingRule(null); }}
|
||||
editRule={editingRule}
|
||||
isBuiltIn={editingRule ? isBuiltIn(editingRule.id) : false}
|
||||
onAdd={(rule) => {
|
||||
if (editingRule) {
|
||||
onChange(rules.map((r) => r.id === editingRule.id ? rule : r));
|
||||
@@ -266,6 +316,12 @@ export default function SettingsTerminalTab(props: {
|
||||
setTerminalThemeId: (id: string) => void;
|
||||
followAppTerminalTheme: boolean;
|
||||
setFollowAppTerminalTheme: (value: boolean) => void;
|
||||
terminalThemeDarkId: string;
|
||||
setTerminalThemeDarkId: (id: string) => void;
|
||||
terminalThemeLightId: string;
|
||||
setTerminalThemeLightId: (id: string) => void;
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
terminalFontFamilyId: string;
|
||||
setTerminalFontFamilyId: (id: string) => void;
|
||||
terminalFontSize: number;
|
||||
@@ -284,6 +340,12 @@ export default function SettingsTerminalTab(props: {
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme,
|
||||
terminalThemeDarkId,
|
||||
setTerminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
setTerminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
terminalFontSize,
|
||||
@@ -315,6 +377,7 @@ export default function SettingsTerminalTab(props: {
|
||||
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
|
||||
}, [discoveredShells, terminalSettings.localShell]);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [themeModalSlot, setThemeModalSlot] = useState<'dark' | 'light' | null>(null);
|
||||
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
@@ -326,6 +389,38 @@ export default function SettingsTerminalTab(props: {
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId, customThemes]);
|
||||
|
||||
// Preview themes for the follow-app per-mode pickers. resolvedTheme is
|
||||
// forced per slot so each preview reflects exactly that mode's selection.
|
||||
const darkPreviewTheme = useMemo(() => {
|
||||
const id = resolveFollowedTerminalThemeId({
|
||||
resolvedTheme: 'dark',
|
||||
terminalThemeDarkId, terminalThemeLightId,
|
||||
lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
return TERMINAL_THEMES.find(t => t.id === id)
|
||||
|| customThemes.find(t => t.id === id)
|
||||
// Mirror the runtime fallback in useSettingsState.currentTerminalTheme:
|
||||
// a deleted per-mode override falls back to the manual theme, not [0].
|
||||
|| TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
|
||||
|
||||
const lightPreviewTheme = useMemo(() => {
|
||||
const id = resolveFollowedTerminalThemeId({
|
||||
resolvedTheme: 'light',
|
||||
terminalThemeDarkId, terminalThemeLightId,
|
||||
lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
return TERMINAL_THEMES.find(t => t.id === id)
|
||||
|| customThemes.find(t => t.id === id)
|
||||
// Mirror the runtime fallback in useSettingsState.currentTerminalTheme:
|
||||
// a deleted per-mode override falls back to the manual theme, not [0].
|
||||
|| TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
|
||||
|
||||
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
|
||||
updateTerminalSetting("autocompleteGhostText", enabled);
|
||||
if (enabled) {
|
||||
@@ -507,7 +602,34 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{!followAppTerminalTheme && (
|
||||
{followAppTerminalTheme ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1.5 px-1">
|
||||
{t("settings.terminal.theme.darkTheme")}
|
||||
</div>
|
||||
<ThemePreviewButton
|
||||
theme={darkPreviewTheme}
|
||||
onClick={() => setThemeModalSlot('dark')}
|
||||
buttonLabel={terminalThemeDarkId === TERMINAL_THEME_AUTO
|
||||
? t("settings.terminal.theme.auto")
|
||||
: t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1.5 px-1">
|
||||
{t("settings.terminal.theme.lightTheme")}
|
||||
</div>
|
||||
<ThemePreviewButton
|
||||
theme={lightPreviewTheme}
|
||||
onClick={() => setThemeModalSlot('light')}
|
||||
buttonLabel={terminalThemeLightId === TERMINAL_THEME_AUTO
|
||||
? t("settings.terminal.theme.auto")
|
||||
: t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ThemePreviewButton
|
||||
theme={currentTheme}
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
@@ -521,6 +643,17 @@ export default function SettingsTerminalTab(props: {
|
||||
selectedThemeId={terminalThemeId}
|
||||
onSelect={setTerminalThemeId}
|
||||
/>
|
||||
<ThemeSelectModal
|
||||
open={themeModalSlot !== null}
|
||||
onClose={() => setThemeModalSlot(null)}
|
||||
selectedThemeId={themeModalSlot === 'dark' ? terminalThemeDarkId : terminalThemeLightId}
|
||||
onSelect={(id) => {
|
||||
if (themeModalSlot === 'dark') setTerminalThemeDarkId(id);
|
||||
else if (themeModalSlot === 'light') setTerminalThemeLightId(id);
|
||||
}}
|
||||
filterType={themeModalSlot === 'light' ? 'light' : 'dark'}
|
||||
showAutoOption
|
||||
/>
|
||||
|
||||
{/* Theme action buttons */}
|
||||
<div className="flex items-center gap-2 -mt-1">
|
||||
@@ -761,6 +894,12 @@ export default function SettingsTerminalTab(props: {
|
||||
>
|
||||
<Toggle checked={terminalSettings.altAsMeta} onChange={(v) => updateTerminalSetting("altAsMeta", v)} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.keyboard.optionArrowWordJump")}
|
||||
description={t("settings.terminal.keyboard.optionArrowWordJump.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.optionArrowWordJump} onChange={(v) => updateTerminalSetting("optionArrowWordJump", v)} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.accessibility")} />
|
||||
@@ -841,6 +980,13 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={terminalSettings.preserveSelectionOnInput ?? false} onChange={(v) => updateTerminalSetting("preserveSelectionOnInput", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.forcePromptNewLine")}
|
||||
description={t("settings.terminal.behavior.forcePromptNewLine.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.forcePromptNewLine ?? false} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
|
||||
@@ -934,6 +1080,29 @@ export default function SettingsTerminalTab(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.startupCommand")} />
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t("settings.terminal.startupCommandDelay.desc")}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("settings.terminal.startupCommandDelay.label")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10000}
|
||||
value={terminalSettings.startupCommandDelayMs}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (!isNaN(val) && val >= 0 && val <= 10000) {
|
||||
updateTerminalSetting("startupCommandDelayMs", val);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.keywordHighlight")} />
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -960,35 +1129,41 @@ export default function SettingsTerminalTab(props: {
|
||||
description={t("settings.terminal.localShell.shell.desc")}
|
||||
>
|
||||
<div className="flex flex-col gap-1 items-end">
|
||||
<select
|
||||
className="h-9 w-48 rounded-md border border-input bg-background px-3 text-sm"
|
||||
<ShadcnSelect
|
||||
value={
|
||||
showCustomShellInput
|
||||
? "__custom__"
|
||||
: terminalSettings.localShell || ""
|
||||
: (terminalSettings.localShell || "__default__")
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onValueChange={(value) => {
|
||||
if (value === "__custom__") {
|
||||
setCustomShellDraft(terminalSettings.localShell || "");
|
||||
setCustomShellModalOpen(true);
|
||||
} else if (value === "__default__") {
|
||||
setShowCustomShellInput(false);
|
||||
updateTerminalSetting("localShell", "");
|
||||
} else {
|
||||
setShowCustomShellInput(false);
|
||||
updateTerminalSetting("localShell", value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{t("settings.terminal.localShell.shell.default")}
|
||||
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
|
||||
</option>
|
||||
{discoveredShells.map((shell) => (
|
||||
<option key={shell.id} value={shell.id}>
|
||||
{shell.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom__">{t("settings.terminal.localShell.shell.custom")}</option>
|
||||
</select>
|
||||
<SelectTrigger className="h-9 w-48 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t("settings.terminal.localShell.shell.default")}
|
||||
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
|
||||
</SelectItem>
|
||||
{discoveredShells.map((shell) => (
|
||||
<SelectItem key={shell.id} value={shell.id}>
|
||||
{shell.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__custom__">{t("settings.terminal.localShell.shell.custom")}</SelectItem>
|
||||
</SelectContent>
|
||||
</ShadcnSelect>
|
||||
{showCustomShellInput && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-48">
|
||||
{terminalSettings.localShell}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ChevronDown, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
import { parseEnvLines, serializeEnvLines } from "./claudeConfigEnv";
|
||||
|
||||
export const ClaudeCodeCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
@@ -12,15 +13,40 @@ export const ClaudeCodeCard: React.FC<{
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
configDir: string;
|
||||
onConfigDirChange: (value: string) => void;
|
||||
envText: string;
|
||||
onEnvTextChange: (value: string) => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
configDir,
|
||||
onConfigDirChange,
|
||||
envText,
|
||||
onEnvTextChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
// Collapsed by default; auto-expand when the user already has config so it
|
||||
// isn't hidden. Local UI state — not persisted.
|
||||
const [configOpen, setConfigOpen] = useState(
|
||||
() => Boolean(configDir.trim() || envText.trim()),
|
||||
);
|
||||
|
||||
// The env editor keeps the raw text the user types. Persisting parses it into
|
||||
// a record (dropping incomplete lines), so binding the textarea directly to
|
||||
// the persisted value would erase a key the moment it's typed before its "=".
|
||||
// Only resync from the persisted value when it changes for some reason other
|
||||
// than our own parse→serialize round-trip.
|
||||
const [envDraft, setEnvDraft] = useState(envText);
|
||||
useEffect(() => {
|
||||
setEnvDraft((prev) =>
|
||||
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
|
||||
);
|
||||
}, [envText]);
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.claude.detecting')
|
||||
@@ -83,6 +109,53 @@ export const ClaudeCodeCard: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Authentication & config (optional, collapsible) */}
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfigOpen((v) => !v)}
|
||||
aria-expanded={configOpen}
|
||||
className="flex w-full items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('ai.claude.configSection')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{configOpen && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="claude-config-dir" className="text-xs text-muted-foreground">{t('ai.claude.configDir')}</label>
|
||||
<input
|
||||
id="claude-config-dir"
|
||||
type="text"
|
||||
value={configDir}
|
||||
onChange={(e) => onConfigDirChange(e.target.value)}
|
||||
placeholder={t('ai.claude.configDir.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.configDir.hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="claude-env-vars" className="text-xs text-muted-foreground">{t('ai.claude.envVars')}</label>
|
||||
<textarea
|
||||
id="claude-env-vars"
|
||||
value={envDraft}
|
||||
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
|
||||
placeholder={t('ai.claude.envVars.placeholder')}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.envVars.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import type { AIProviderId } from "../../../../infrastructure/ai/types";
|
||||
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { FetchedModel } from "./types";
|
||||
import { getFetchBridge } from "./types";
|
||||
@@ -15,8 +18,10 @@ export const ModelSelector: React.FC<{
|
||||
placeholder?: string;
|
||||
apiKey?: string;
|
||||
providerId?: AIProviderId;
|
||||
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
|
||||
style?: ProviderStyle;
|
||||
skipTLSVerify?: boolean;
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, skipTLSVerify }) => {
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, style, skipTLSVerify }) => {
|
||||
const { t } = useI18n();
|
||||
const [models, setModels] = useState<FetchedModel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -24,12 +29,19 @@ export const ModelSelector: React.FC<{
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
|
||||
// Resolve the wire-protocol family: prefer an explicit style override (set in
|
||||
// the form), then fall back to the providerId-derived default.
|
||||
const resolvedStyle: ProviderStyle = style
|
||||
?? (providerId ? resolveProviderStyle({ providerId }) : "openai");
|
||||
// Endpoint follows the resolved style so a providerId+style mismatch (e.g.
|
||||
// Anthropic providerId switched to OpenAI style) still hits the right path.
|
||||
const effectiveModelsEndpoint = resolveModelsDiscoveryEndpoint(resolvedStyle, modelsEndpoint);
|
||||
// Ollama runs locally without auth; all other providers need an API key to list models
|
||||
const needsApiKey = providerId !== "ollama";
|
||||
const canFetch = !!modelsEndpoint && (!needsApiKey || !!apiKey);
|
||||
const canFetch = !!effectiveModelsEndpoint && (!needsApiKey || !!apiKey);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
if (!modelsEndpoint) return;
|
||||
if (!effectiveModelsEndpoint) return;
|
||||
const bridge = getFetchBridge();
|
||||
if (!bridge?.aiFetch) return;
|
||||
|
||||
@@ -41,16 +53,8 @@ export const ModelSelector: React.FC<{
|
||||
if (bridge.aiAllowlistAddHost && baseURL) {
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = `${baseURL.replace(/\/+$/, "")}${modelsEndpoint}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) {
|
||||
if (providerId === "anthropic") {
|
||||
headers["x-api-key"] = apiKey;
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
}
|
||||
const url = `${baseURL.replace(/\/+$/, "")}${effectiveModelsEndpoint}`;
|
||||
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
|
||||
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
|
||||
if (!result.ok) {
|
||||
setError(`Failed to fetch models (${result.error || "unknown error"})`);
|
||||
@@ -69,7 +73,7 @@ export const ModelSelector: React.FC<{
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [baseURL, modelsEndpoint, apiKey, providerId, skipTLSVerify]);
|
||||
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
useEffect(() => {
|
||||
@@ -120,16 +124,20 @@ export const ModelSelector: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
{canFetch && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setHasFetched(false); void fetchModels(); }}
|
||||
disabled={isLoading}
|
||||
className="shrink-0 px-2"
|
||||
title={t('ai.providers.refreshModels')}
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setHasFetched(false); void fetchModels(); }}
|
||||
disabled={isLoading}
|
||||
className="shrink-0 px-2"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.providers.refreshModels')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Toggle } from "../../settings-ui";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
import { ProviderConfigForm } from "./ProviderConfigForm";
|
||||
@@ -29,7 +30,7 @@ export const ProviderCard: React.FC<{
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Provider icon */}
|
||||
<ProviderIconBadge providerId={provider.providerId} />
|
||||
<ProviderIconBadge provider={provider} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -61,20 +62,28 @@ export const ProviderCard: React.FC<{
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={t('ai.providers.configure')}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title={t('ai.providers.remove')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.providers.configure')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.providers.remove')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Toggle checked={provider.enabled} onChange={onToggleEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,51 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { BuiltinProviderIcon } from "./types";
|
||||
import { BUILTIN_PROVIDER_ICONS } from "./types";
|
||||
import type { ProviderFormState } from "./types";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
const ICON_PIXEL_SIZE = 64;
|
||||
const ICON_WEBP_QUALITY = 0.85;
|
||||
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
async function compressIconFileToDataUrl(file: File): Promise<string> {
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
throw new Error("Image too large; please use an image under 5 MB.");
|
||||
}
|
||||
const sourceUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const el = new Image();
|
||||
el.onload = () => resolve(el);
|
||||
el.onerror = () => reject(new Error("Failed to decode image"));
|
||||
el.src = sourceUrl;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = ICON_PIXEL_SIZE;
|
||||
canvas.height = ICON_PIXEL_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
||||
ctx.clearRect(0, 0, ICON_PIXEL_SIZE, ICON_PIXEL_SIZE);
|
||||
const scale = Math.min(ICON_PIXEL_SIZE / img.width, ICON_PIXEL_SIZE / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
ctx.drawImage(img, (ICON_PIXEL_SIZE - w) / 2, (ICON_PIXEL_SIZE - h) / 2, w, h);
|
||||
return canvas.toDataURL("image/webp", ICON_WEBP_QUALITY);
|
||||
}
|
||||
|
||||
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
|
||||
|
||||
export const ProviderConfigForm: React.FC<{
|
||||
provider: ProviderConfig;
|
||||
@@ -14,6 +53,8 @@ export const ProviderConfigForm: React.FC<{
|
||||
onCancel: () => void;
|
||||
}> = ({ provider, onSave, onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [form, setForm] = useState<ProviderFormState>({
|
||||
name: provider.name ?? PROVIDER_PRESETS[provider.providerId]?.name ?? "",
|
||||
apiKey: "",
|
||||
@@ -21,13 +62,24 @@ export const ProviderConfigForm: React.FC<{
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
skipTLSVerify: provider.skipTLSVerify ?? false,
|
||||
advancedParams: provider.advancedParams ?? {},
|
||||
style: provider.style ?? "",
|
||||
iconId: provider.iconId ?? "",
|
||||
iconDataUrl: provider.iconDataUrl ?? "",
|
||||
});
|
||||
const isCustom = provider.providerId === "custom";
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const [iconError, setIconError] = useState<string | null>(null);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
|
||||
const previewProvider: Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl"> = {
|
||||
providerId: provider.providerId,
|
||||
name: form.name,
|
||||
iconId: form.iconId || undefined,
|
||||
iconDataUrl: form.iconDataUrl || undefined,
|
||||
};
|
||||
|
||||
// Decrypt and load existing API key on mount
|
||||
useEffect(() => {
|
||||
@@ -62,6 +114,31 @@ export const ProviderConfigForm: React.FC<{
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleIconFileSelect = useCallback(async (file: File | null) => {
|
||||
setIconError(null);
|
||||
if (!file) return;
|
||||
if (!/^image\//.test(file.type)) {
|
||||
setIconError(t("ai.providers.icon.errorType"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dataUrl = await compressIconFileToDataUrl(file);
|
||||
setForm((prev) => ({ ...prev, iconDataUrl: dataUrl, iconId: "" }));
|
||||
} catch (err) {
|
||||
setIconError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handlePickBuiltin = useCallback((icon: BuiltinProviderIcon) => {
|
||||
setIconError(null);
|
||||
setForm((prev) => ({ ...prev, iconId: icon.id, iconDataUrl: "", name: icon.name }));
|
||||
}, []);
|
||||
|
||||
const handleResetIcon = useCallback(() => {
|
||||
setIconError(null);
|
||||
setForm((prev) => ({ ...prev, iconId: "", iconDataUrl: "" }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const cleanedParams: ProviderAdvancedParams = {};
|
||||
const ap = form.advancedParams;
|
||||
@@ -71,12 +148,18 @@ export const ProviderConfigForm: React.FC<{
|
||||
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
|
||||
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
|
||||
|
||||
const trimmedName = form.name.trim();
|
||||
const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? "";
|
||||
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
name: trimmedName || defaultName,
|
||||
baseURL: form.baseURL || undefined,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
skipTLSVerify: form.skipTLSVerify || undefined,
|
||||
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
|
||||
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
style: form.style || undefined,
|
||||
iconId: form.iconId || undefined,
|
||||
iconDataUrl: form.iconDataUrl || undefined,
|
||||
};
|
||||
|
||||
// Encrypt API key before saving
|
||||
@@ -87,23 +170,133 @@ export const ProviderConfigForm: React.FC<{
|
||||
}
|
||||
|
||||
onSave(updates);
|
||||
}, [form, onSave, isCustom]);
|
||||
}, [form, onSave, provider.providerId]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
|
||||
{/* Name (custom providers only) */}
|
||||
{isCustom && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.name')}</label>
|
||||
{/* Display: icon + name */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.name')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIconPicker((v) => !v)}
|
||||
className="group relative shrink-0 rounded-md transition-all hover:brightness-110 hover:ring-2 hover:ring-primary/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
aria-label={t('ai.providers.icon.change')}
|
||||
title={t('ai.providers.icon.change')}
|
||||
>
|
||||
<ProviderIconBadge provider={previewProvider} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
>
|
||||
<Pencil size={9} strokeWidth={2.5} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('ai.providers.name.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showIconPicker && (
|
||||
<div className="rounded-md border border-border/50 bg-muted/20 p-2 space-y-2">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-1.5">
|
||||
{BUILTIN_PROVIDER_ICONS.map((icon) => {
|
||||
const isSelected = form.iconId === icon.id && !form.iconDataUrl;
|
||||
return (
|
||||
<button
|
||||
key={icon.id}
|
||||
type="button"
|
||||
onClick={() => (isSelected ? handleResetIcon() : handlePickBuiltin(icon))}
|
||||
title={icon.label}
|
||||
aria-label={icon.label}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded-md border text-left transition-colors min-w-0",
|
||||
isSelected
|
||||
? "border-primary/70 bg-primary/15"
|
||||
: "border-transparent hover:border-border hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<ProviderIconBadge
|
||||
provider={{ providerId: provider.providerId, name: icon.label, iconId: icon.id }}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-xs text-foreground/85 truncate">{icon.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleIconFileSelect(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload size={12} className="mr-1.5" />
|
||||
{t('ai.providers.icon.upload')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleResetIcon}>
|
||||
<RotateCcw size={12} className="mr-1.5" />
|
||||
{t('ai.providers.icon.reset')}
|
||||
</Button>
|
||||
{form.iconDataUrl && (
|
||||
<span className="text-[10px] text-muted-foreground">{t('ai.providers.icon.uploadedNote')}</span>
|
||||
)}
|
||||
<div className="ml-auto" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowIconPicker(false)}
|
||||
aria-label={t('ai.providers.icon.close')}
|
||||
title={t('ai.providers.icon.close')}
|
||||
>
|
||||
<X size={12} className="mr-1.5" />
|
||||
{t('ai.providers.icon.close')}
|
||||
</Button>
|
||||
</div>
|
||||
{iconError && <p className="text-[11px] text-destructive">{iconError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider style */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.style')}</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{STYLE_OPTIONS.map((style) => {
|
||||
const isSelected = resolvedStyle === style;
|
||||
const isInherited = !form.style && isSelected;
|
||||
return (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, style: prev.style === style ? "" : style }))}
|
||||
className={cn(
|
||||
"h-7 px-2.5 rounded-md text-xs border transition-colors",
|
||||
isSelected
|
||||
? "border-primary/70 bg-primary/15 text-foreground"
|
||||
: "border-border/50 bg-background text-muted-foreground hover:text-foreground hover:bg-muted/40",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{t(`ai.providers.style.${style}`)}
|
||||
{isInherited && (
|
||||
<span className="ml-1 text-[9px] text-muted-foreground/70">({t('ai.providers.style.inherited')})</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.style.help')}</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.apiKey')}</label>
|
||||
@@ -150,6 +343,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
modelsEndpoint={preset?.modelsEndpoint}
|
||||
apiKey={form.apiKey}
|
||||
providerId={provider.providerId}
|
||||
style={resolvedStyle}
|
||||
skipTLSVerify={form.skipTLSVerify}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,117 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import type { SettingsIconId } from "./types";
|
||||
import { SETTINGS_ICON_PATHS, SETTINGS_ICON_COLORS } from "./types";
|
||||
import {
|
||||
BUILTIN_PROVIDER_ICON_BY_ID,
|
||||
SETTINGS_ICON_PATHS,
|
||||
SETTINGS_ICON_COLORS,
|
||||
} from "./types";
|
||||
|
||||
export const ProviderIconBadge: React.FC<{
|
||||
providerId: SettingsIconId;
|
||||
size?: "sm" | "md";
|
||||
}> = ({ providerId, size = "md" }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md flex items-center justify-center shrink-0 overflow-hidden",
|
||||
size === "sm" ? "w-5 h-5" : "w-8 h-8",
|
||||
SETTINGS_ICON_COLORS[providerId],
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={SETTINGS_ICON_PATHS[providerId]}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
/**
|
||||
* Optional ProviderConfig-like shape for per-provider customization. Only the
|
||||
* fields used by the badge are listed so non-provider call sites (Claude/Copilot
|
||||
* agent cards) can still pass a bare `providerId`.
|
||||
*/
|
||||
type ProviderLike = Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl">;
|
||||
|
||||
interface BaseProps {
|
||||
size?: "xs" | "sm" | "md";
|
||||
}
|
||||
|
||||
type Props =
|
||||
| (BaseProps & { providerId: SettingsIconId; provider?: undefined })
|
||||
| (BaseProps & { provider: ProviderLike; providerId?: undefined });
|
||||
|
||||
const BADGE_DIMENSIONS = {
|
||||
xs: "w-4 h-4",
|
||||
sm: "w-5 h-5",
|
||||
md: "w-8 h-8",
|
||||
} as const;
|
||||
|
||||
const IMG_DIMENSIONS = {
|
||||
xs: "w-2.5 h-2.5",
|
||||
sm: "w-3 h-3",
|
||||
md: "w-4 h-4",
|
||||
} as const;
|
||||
|
||||
const UPLOAD_IMG_DIMENSIONS = {
|
||||
xs: "w-4 h-4",
|
||||
sm: "w-5 h-5",
|
||||
md: "w-8 h-8",
|
||||
} as const;
|
||||
|
||||
export const ProviderIconBadge: React.FC<Props> = (props) => {
|
||||
const size = props.size ?? "md";
|
||||
const dim = BADGE_DIMENSIONS[size];
|
||||
|
||||
// Branch 1: user-uploaded data URL — render verbatim, no filter, neutral bg.
|
||||
if (props.provider?.iconDataUrl) {
|
||||
return (
|
||||
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden bg-zinc-900/40", dim)}>
|
||||
<img
|
||||
src={props.provider.iconDataUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn("object-contain", UPLOAD_IMG_DIMENSIONS[size])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Branch 2: built-in iconId (lobe-icons subset).
|
||||
const iconId = props.provider?.iconId;
|
||||
if (iconId) {
|
||||
const builtin = BUILTIN_PROVIDER_ICON_BY_ID[iconId];
|
||||
if (builtin) {
|
||||
return (
|
||||
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden", dim, builtin.bgColor)}>
|
||||
<img
|
||||
src={builtin.path}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn("object-contain brightness-0 invert", IMG_DIMENSIONS[size])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Branch 3: providerId → existing built-in fallback table.
|
||||
const fallbackId: SettingsIconId | undefined =
|
||||
props.providerId ?? (props.provider ? (props.provider.providerId as SettingsIconId) : undefined);
|
||||
if (fallbackId && fallbackId in SETTINGS_ICON_PATHS) {
|
||||
return (
|
||||
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden", dim, SETTINGS_ICON_COLORS[fallbackId])}>
|
||||
<img
|
||||
src={SETTINGS_ICON_PATHS[fallbackId]}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"object-contain",
|
||||
fallbackId === "copilot" ? "brightness-0" : "brightness-0 invert",
|
||||
IMG_DIMENSIONS[size],
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Branch 4: letter avatar from the provider name.
|
||||
const letter = (props.provider?.name?.trim().charAt(0) ?? "?").toUpperCase();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"object-contain",
|
||||
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
|
||||
size === "sm" ? "w-3 h-3" : "w-4 h-4",
|
||||
"rounded-md flex items-center justify-center shrink-0 overflow-hidden bg-zinc-600 text-white font-medium",
|
||||
dim,
|
||||
size === "md" ? "text-sm" : size === "sm" ? "text-[10px]" : "text-[9px]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
aria-hidden="true"
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
65
components/settings/tabs/ai/claudeConfigEnv.ts
Normal file
65
components/settings/tabs/ai/claudeConfigEnv.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pure helpers for the Claude Code card's "config directory + environment
|
||||
* variables" editor. The managed Claude agent stores everything in its
|
||||
* ExternalAgentConfig.env; this splits that into the editable pieces and
|
||||
* recombines them. CLAUDE_CODE_EXECUTABLE is owned by path discovery, so it
|
||||
* is preserved across edits but never shown in the env editor.
|
||||
*/
|
||||
|
||||
const CONFIG_DIR_KEY = "CLAUDE_CONFIG_DIR";
|
||||
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY]);
|
||||
|
||||
export function parseEnvLines(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const rawLine of String(text || "").split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
const value = line.slice(eq + 1).trim();
|
||||
if (key) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function serializeEnvLines(env: Record<string, string>): string {
|
||||
return Object.entries(env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function splitClaudeEnv(
|
||||
env: Record<string, string> | undefined,
|
||||
): { configDir: string; envText: string } {
|
||||
if (!env) return { configDir: "", envText: "" };
|
||||
const configDir = env[CONFIG_DIR_KEY] ?? "";
|
||||
const rest: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(env)) {
|
||||
if (MANAGED_KEYS.has(k)) continue;
|
||||
rest[k] = v;
|
||||
}
|
||||
return { configDir, envText: serializeEnvLines(rest) };
|
||||
}
|
||||
|
||||
export function buildClaudeEnv(
|
||||
prevEnv: Record<string, string> | undefined,
|
||||
configDir: string,
|
||||
envText: string,
|
||||
): Record<string, string> | undefined {
|
||||
const next: Record<string, string> = {};
|
||||
// Preserve discovery-owned key if present.
|
||||
const exe = prevEnv?.CLAUDE_CODE_EXECUTABLE;
|
||||
if (exe) next.CLAUDE_CODE_EXECUTABLE = exe;
|
||||
|
||||
const trimmedDir = String(configDir || "").trim();
|
||||
if (trimmedDir) next[CONFIG_DIR_KEY] = trimmedDir;
|
||||
|
||||
// Drop managed keys if a user typed them into the free-text editor — the
|
||||
// config-dir field and path discovery own CLAUDE_CONFIG_DIR / CLAUDE_CODE_EXECUTABLE.
|
||||
const parsed = parseEnvLines(envText);
|
||||
for (const key of MANAGED_KEYS) delete parsed[key];
|
||||
Object.assign(next, parsed);
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user