Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb5333e336 | ||
|
|
d3153148c8 | ||
|
|
899cb109b4 | ||
|
|
d031bf355d | ||
|
|
489b7711f5 | ||
|
|
65877fd912 | ||
|
|
117ec260b6 | ||
|
|
c76ff7ac9a | ||
|
|
17da21b1cd | ||
|
|
733e36a728 | ||
|
|
35174246cc | ||
|
|
ab13670eaa | ||
|
|
4f3e39e378 | ||
|
|
2281d1df68 | ||
|
|
e570185e2f | ||
|
|
12884165b5 | ||
|
|
11f82defc3 | ||
|
|
ac9175b770 | ||
|
|
71c6f68934 | ||
|
|
01bee794ee | ||
|
|
29dc01306d | ||
|
|
0dcfd1489b | ||
|
|
72f61141c4 | ||
|
|
37150ea379 | ||
|
|
5706af3f33 | ||
|
|
6871c82ab8 | ||
|
|
b90ff692eb | ||
|
|
ce71725dba | ||
|
|
fb5c4aaa58 | ||
|
|
45c059ae53 | ||
|
|
1d67eb40c4 | ||
|
|
c6e3989a1b | ||
|
|
ace081414f | ||
|
|
049a609bca | ||
|
|
44409e6d32 | ||
|
|
5246489ef9 | ||
|
|
83d0d917ad | ||
|
|
73557d0af1 | ||
|
|
aa67455c8c | ||
|
|
c7d2482996 | ||
|
|
d2391f5472 | ||
|
|
9be84c71f5 | ||
|
|
effb98b91a | ||
|
|
77fd7a42a8 | ||
|
|
a86a5e6839 | ||
|
|
7ed4940e18 | ||
|
|
410d1ef097 | ||
|
|
c386ee2e2e | ||
|
|
4c08888b60 | ||
|
|
2ea4c88680 | ||
|
|
0ba75f9af0 | ||
|
|
4610348b0d | ||
|
|
8d11b71bc1 | ||
|
|
6683001032 | ||
|
|
3b313ff933 | ||
|
|
eaa27461fa | ||
|
|
20b65366be | ||
|
|
b8c08ba3ca | ||
|
|
981c5de90d | ||
|
|
0097d65a6e | ||
|
|
e4aa03c474 | ||
|
|
b94386236c | ||
|
|
0883585704 | ||
|
|
5b38f4663d | ||
|
|
a6a6dd1aac | ||
|
|
506c60ea44 | ||
|
|
9d9b24fe7b | ||
|
|
584b9859ef | ||
|
|
b005065949 | ||
|
|
a4fdb6758d | ||
|
|
a2b5c9d067 |
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -59,12 +59,12 @@ jobs:
|
||||
- name: Build package
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
# macOS code signing & notarization (ignored on other platforms)
|
||||
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
# macOS code signing & notarization (only for macOS builds)
|
||||
CSC_LINK: ${{ matrix.name == 'macos' && secrets.MAC_CSC_LINK || '' }}
|
||||
CSC_KEY_PASSWORD: ${{ matrix.name == 'macos' && secrets.MAC_CSC_KEY_PASSWORD || '' }}
|
||||
APPLE_ID: ${{ matrix.name == 'macos' && secrets.APPLE_ID || '' }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.name == 'macos' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
|
||||
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Upload artifacts
|
||||
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -37,3 +37,23 @@ coverage
|
||||
|
||||
# Claude Code local settings
|
||||
/.claude/settings.local.json
|
||||
/CLAUDE.md
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
/docs/superpowers/
|
||||
|
||||
# Dev-only electron-updater test config (not for production)
|
||||
/dev-app-update.yml
|
||||
|
||||
# Test suite (local only, not committed)
|
||||
/tests/
|
||||
/vitest.config.ts
|
||||
|
||||
# Serena MCP project config (local only)
|
||||
/.serena/
|
||||
|
||||
# Windows VS Build environment scripts (local dev only)
|
||||
Directory.Build.props
|
||||
Directory.Build.targets
|
||||
build_with_vs.bat
|
||||
build_with_vs2022.bat
|
||||
|
||||
46
App.tsx
46
App.tsx
@@ -283,6 +283,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
onApplyPayload: (payload) => {
|
||||
@@ -304,13 +305,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [handleSyncNow]);
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, dismissUpdate } = useUpdateCheck();
|
||||
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
|
||||
|
||||
// Window controls - must be before update toast effect which uses openSettingsWindow
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
|
||||
// Show toast notification when update is available
|
||||
// Show toast notification when update is available (only when auto-download is idle)
|
||||
useEffect(() => {
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
@@ -320,13 +323,50 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
duration: 8000, // Show longer for update notifications
|
||||
onClick: () => {
|
||||
void openSettingsWindow();
|
||||
// Dismiss the update so the toast doesn't re-fire on every render.
|
||||
// On unsupported platforms (where autoDownloadStatus stays 'idle')
|
||||
// this is the only way to suppress the notification for this version.
|
||||
// On supported platforms this toast only shows before auto-download
|
||||
// starts, and the Settings window's own useUpdateCheck will pick up
|
||||
// the download state via IPC events independently of the dismiss.
|
||||
dismissUpdate();
|
||||
},
|
||||
actionLabel: t('update.viewInSettings'),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, t, openSettingsWindow, dismissUpdate]);
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
|
||||
|
||||
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
|
||||
// not when unrelated deps (openReleasePage, installUpdate) change their reference.
|
||||
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
|
||||
useEffect(() => {
|
||||
const prev = prevAutoDownloadStatusRef.current;
|
||||
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
|
||||
if (prev === updateState.autoDownloadStatus) return;
|
||||
|
||||
if (updateState.autoDownloadStatus === 'ready') {
|
||||
const version = updateState.latestRelease?.version ?? '';
|
||||
toast.info(
|
||||
t('update.readyToInstall.message', { version }),
|
||||
{
|
||||
title: t('update.readyToInstall.title'),
|
||||
duration: 0,
|
||||
actionLabel: t('update.restartNow'),
|
||||
onClick: () => installUpdate(),
|
||||
}
|
||||
);
|
||||
} else if (updateState.autoDownloadStatus === 'error') {
|
||||
toast.error(
|
||||
t('update.downloadFailed.message'),
|
||||
{
|
||||
title: t('update.downloadFailed.title'),
|
||||
actionLabel: t('update.openReleases'),
|
||||
onClick: () => openReleasePage(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
|
||||
|
||||
// Memoize keys for port forwarding to prevent unnecessary re-renders
|
||||
const portForwardingKeys = useMemo(
|
||||
|
||||
32
CHANGELOG.md
Normal file
32
CHANGELOG.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased] - 2026-03-11
|
||||
|
||||
### 功能
|
||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
|
||||
- 设置窗口中点击"检查更新"后,下载进度可实时反映在 UI 中
|
||||
- 应用启动后 5 秒自动触发 `electron-updater` 检查更新,无需用户手动点击
|
||||
- 发现新版本后自动开始下载(`autoDownload=true`)
|
||||
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
|
||||
- 下载失败时弹出错误 toast,提供"打开 Releases"降级入口
|
||||
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
|
||||
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
|
||||
### 设计原理
|
||||
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
|
||||
- `manualCheckStatus` 字段追踪手动检查 UI 状态(idle/checking/available/up-to-date/error),与 `autoDownloadStatus` 在 UI 层按优先级渲染
|
||||
- `SettingsSystemTab` 不再持有本地 update state,单向接收 `useUpdateCheck` 统一数据
|
||||
- 将原有两套独立系统(GitHub API 通知 + electron-updater 手动下载)合并为统一状态机:`useUpdateCheck` 作为唯一事实来源,同时驱动 `App.tsx` toast 和 `SettingsSystemTab` 进度条
|
||||
- 全局持久化 IPC 监听器在 `autoUpdateBridge.init()` 时一次性注册,避免每次手动下载请求重复注册/清理监听器
|
||||
- `autoInstallOnAppQuit=false`,不做静默安装,由用户主动触发重启
|
||||
|
||||
### 接口变更(SettingsSystemTabProps)
|
||||
- 移除:`autoDownloadStatus`、`downloadPercent`
|
||||
- 新增:`updateState`(完整 UpdateState)、`checkNow`、`installUpdate`、`openReleasePage`
|
||||
|
||||
### 注意事项
|
||||
- `checkNow` 语义:使用 GitHub API(`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
|
||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复
|
||||
@@ -109,6 +109,10 @@ const en: Messages = {
|
||||
'settings.update.manualDownload': 'Download from GitHub',
|
||||
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
|
||||
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
|
||||
'settings.update.lastCheckedJustNow': 'just now',
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
|
||||
'settings.update.lastCheckedPrefix': 'Last checked: ',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
@@ -177,6 +181,12 @@ const en: Messages = {
|
||||
'update.error': 'Failed to check for updates',
|
||||
'update.downloadNow': 'Download Now',
|
||||
'update.viewInSettings': 'View in Settings',
|
||||
'update.readyToInstall.title': 'Update Ready',
|
||||
'update.readyToInstall.message': 'Version {version} downloaded and ready to install.',
|
||||
'update.restartNow': 'Restart Now',
|
||||
'update.downloadFailed.title': 'Update Failed',
|
||||
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
|
||||
'update.openReleases': 'Open Releases',
|
||||
'update.remindLater': 'Remind Later',
|
||||
'update.skipVersion': 'Skip This Version',
|
||||
|
||||
@@ -247,7 +257,7 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.rightClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
|
||||
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Shift to select',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Option on macOS or Shift on Windows/Linux to select',
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
|
||||
@@ -93,6 +93,10 @@ const zhCN: Messages = {
|
||||
'settings.update.manualDownload': '前往 GitHub 下载',
|
||||
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
|
||||
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
|
||||
'settings.update.lastCheckedJustNow': '刚刚',
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
|
||||
'settings.update.lastCheckedPrefix': '上次检查:',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
@@ -161,6 +165,12 @@ const zhCN: Messages = {
|
||||
'update.error': '检查更新失败',
|
||||
'update.downloadNow': '立即下载',
|
||||
'update.viewInSettings': '在设置中查看',
|
||||
'update.readyToInstall.title': '更新已就绪',
|
||||
'update.readyToInstall.message': '版本 {version} 已下载完成,准备安装。',
|
||||
'update.restartNow': '立即重启',
|
||||
'update.downloadFailed.title': '更新失败',
|
||||
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
|
||||
'update.openReleases': '打开 Releases',
|
||||
'update.remindLater': '稍后提醒',
|
||||
'update.skipVersion': '跳过此版本',
|
||||
|
||||
@@ -1119,7 +1129,7 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.rightClick.paste': '粘贴',
|
||||
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
|
||||
'settings.terminal.behavior.copyOnSelect': '选择即复制',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface SftpPane {
|
||||
selectedFiles: Set<string>;
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
@@ -22,7 +23,10 @@ export interface SftpSideTabs {
|
||||
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
|
||||
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
|
||||
|
||||
export const createEmptyPane = (id?: string): SftpPane => ({
|
||||
export const createEmptyPane = (
|
||||
id?: string,
|
||||
showHiddenFiles = false,
|
||||
): SftpPane => ({
|
||||
id: id || crypto.randomUUID(),
|
||||
connection: null,
|
||||
files: [],
|
||||
@@ -32,6 +36,7 @@ export const createEmptyPane = (id?: string): SftpPane => ({
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles,
|
||||
});
|
||||
|
||||
// File watch event types
|
||||
@@ -53,4 +58,5 @@ export interface SftpStateOptions {
|
||||
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
|
||||
onFileWatchError?: (event: FileWatchErrorEvent) => void;
|
||||
useCompressedUpload?: boolean;
|
||||
defaultShowHiddenFiles?: boolean;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ interface UseSftpConnectionsParams {
|
||||
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
createEmptyPane: (id?: string) => SftpPane;
|
||||
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
@@ -346,6 +346,7 @@ export const useSftpConnections = ({
|
||||
getActivePane,
|
||||
updateTab,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
makeCacheKey,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
@@ -412,7 +413,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
}
|
||||
|
||||
updateTab(side, activeTabId, () => createEmptyPane(activeTabId));
|
||||
updateTab(side, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, clearCacheForConnection, updateTab],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
@@ -68,6 +68,18 @@ export const useSftpPaneActions = ({
|
||||
isSessionError,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
// Track the latest navigation request ID per tab, so we can distinguish
|
||||
// whether a superseded request was superseded by the same tab or a different tab.
|
||||
const tabNavSeqRef = useRef(new Map<string, number>());
|
||||
|
||||
// Track the last confirmed (successfully loaded) state per tab, so that
|
||||
// restore-on-error/supersede always reverts to a known-good state rather
|
||||
// than an intermediate optimistic state from another in-flight navigation.
|
||||
// Includes connectionId so stale entries from a previous host are ignored.
|
||||
const lastConfirmedRef = useRef(
|
||||
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string> }>(),
|
||||
);
|
||||
|
||||
const navigateTo = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
@@ -92,8 +104,9 @@ export const useSftpPaneActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
|
||||
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
|
||||
const cached = options?.force
|
||||
? undefined
|
||||
: dirCacheRef.current.get(cacheKey);
|
||||
@@ -104,6 +117,13 @@ export const useSftpPaneActions = ({
|
||||
cached.files
|
||||
) {
|
||||
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files: cached.files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
@@ -118,7 +138,36 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
|
||||
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
|
||||
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
|
||||
// Re-seed confirmed state whenever the pane is settled (not loading), or
|
||||
// when the connection has changed. This captures post-mutation state from
|
||||
// optimistic updates (e.g. deleteFilesAtPath) so that a failed refresh
|
||||
// doesn't resurrect deleted items.
|
||||
const existing = lastConfirmedRef.current.get(activeTabId);
|
||||
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path: pane.connection.currentPath,
|
||||
files: pane.files,
|
||||
selectedFiles: pane.selectedFiles,
|
||||
});
|
||||
}
|
||||
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
|
||||
const previousPath = confirmed.path;
|
||||
const previousFiles = confirmed.files;
|
||||
const previousSelection = confirmed.selectedFiles;
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
// Keep existing files visible during loading — the loading overlay
|
||||
// (pointer-events-none) prevents interaction. This avoids blanking a tab
|
||||
// that gets superseded by another tab navigating on the same side.
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
selectedFiles: new Set(),
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
let files: SftpFileEntry[];
|
||||
@@ -164,13 +213,42 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (navSeqRef.current[side] !== requestId) return;
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
// Another navigation on this side superseded this request.
|
||||
// Only restore if no newer navigation has occurred on this specific tab
|
||||
// AND the tab still belongs to the same connection (connect/disconnect
|
||||
// bump navSeqRef but not tabNavSeqRef).
|
||||
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
// Tab was reconnected or disconnected; don't restore stale state.
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
@@ -181,13 +259,38 @@ export const useSftpPaneActions = ({
|
||||
selectedFiles: new Set(),
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== requestId) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
}));
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SftpTabsState {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
|
||||
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
|
||||
addTab: (side: "left" | "right") => string;
|
||||
closeTab: (side: "left" | "right", tabId: string) => void;
|
||||
selectTab: (side: "left" | "right", tabId: string) => void;
|
||||
@@ -33,7 +34,11 @@ export interface SftpTabsState {
|
||||
getActiveTabId: (side: "left" | "right") => string | null;
|
||||
}
|
||||
|
||||
export const useSftpTabsState = (): SftpTabsState => {
|
||||
export const useSftpTabsState = ({
|
||||
defaultShowHiddenFiles = false,
|
||||
}: {
|
||||
defaultShowHiddenFiles?: boolean;
|
||||
} = {}): SftpTabsState => {
|
||||
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
@@ -45,8 +50,10 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
|
||||
const leftTabsRef = useRef(leftTabs);
|
||||
const rightTabsRef = useRef(rightTabs);
|
||||
const defaultShowHiddenFilesRef = useRef(defaultShowHiddenFiles);
|
||||
leftTabsRef.current = leftTabs;
|
||||
rightTabsRef.current = rightTabs;
|
||||
defaultShowHiddenFilesRef.current = defaultShowHiddenFiles;
|
||||
|
||||
const getActivePane = useCallback((side: "left" | "right"): SftpPane | null => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
@@ -58,14 +65,14 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
const pane = leftTabs.activeTabId
|
||||
? leftTabs.tabs.find((t) => t.id === leftTabs.activeTabId)
|
||||
: null;
|
||||
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID);
|
||||
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID, defaultShowHiddenFilesRef.current);
|
||||
}, [leftTabs]);
|
||||
|
||||
const rightPane = useMemo(() => {
|
||||
const pane = rightTabs.activeTabId
|
||||
? rightTabs.tabs.find((t) => t.id === rightTabs.activeTabId)
|
||||
: null;
|
||||
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID);
|
||||
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID, defaultShowHiddenFilesRef.current);
|
||||
}, [rightTabs]);
|
||||
|
||||
const updateTab = useCallback(
|
||||
@@ -88,9 +95,24 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const setTabShowHiddenFiles = useCallback(
|
||||
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
|
||||
updateTab(side, tabId, (prev) => {
|
||||
if (prev.showHiddenFiles === showHiddenFiles) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
showHiddenFiles,
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const addTab = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
const newPane = createEmptyPane();
|
||||
const newPane = createEmptyPane(undefined, defaultShowHiddenFilesRef.current);
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => ({
|
||||
tabs: [...prev.tabs, newPane],
|
||||
@@ -236,6 +258,7 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
selectTab,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
@@ -27,6 +27,7 @@ interface AutoSyncConfig {
|
||||
identities?: SyncPayload['identities'];
|
||||
snippets: SyncPayload['snippets'];
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
|
||||
@@ -36,6 +37,7 @@ interface AutoSyncConfig {
|
||||
|
||||
// Get manager singleton for direct state access
|
||||
const manager = getCloudSyncManager();
|
||||
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
|
||||
type SyncTrigger = 'auto' | 'manual';
|
||||
|
||||
@@ -76,11 +78,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
identities: config.identities,
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: config.knownHosts,
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
|
||||
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.snippetPackages, config.portForwardingRules, config.knownHosts]);
|
||||
|
||||
// Create a hash of current data for comparison
|
||||
const getDataHash = useCallback(() => {
|
||||
@@ -105,11 +108,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
identities: config.identities,
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: config.knownHosts,
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
|
||||
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.snippetPackages, config.portForwardingRules, config.knownHosts]);
|
||||
|
||||
// Sync now handler - get fresh state directly from manager
|
||||
const syncNow = useCallback(async (options?: SyncNowOptions) => {
|
||||
@@ -119,7 +123,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// Get fresh state directly from CloudSyncManager singleton
|
||||
let state = manager.getState();
|
||||
|
||||
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const syncing = state.syncState === 'SYNCING';
|
||||
|
||||
if (!hasProvider) {
|
||||
@@ -181,7 +185,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
|
||||
@@ -191,12 +195,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
hasCheckedRemoteRef.current = true;
|
||||
|
||||
// Find connected provider
|
||||
const connectedProvider =
|
||||
state.providers.github.status === 'connected' ? 'github' :
|
||||
state.providers.google.status === 'connected' ? 'google' :
|
||||
state.providers.onedrive.status === 'connected' ? 'onedrive' :
|
||||
state.providers.webdav.status === 'connected' ? 'webdav' :
|
||||
state.providers.s3.status === 'connected' ? 's3' : null;
|
||||
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
|
||||
isProviderReadyForSync(state.providers[provider]),
|
||||
) ?? null;
|
||||
|
||||
if (!connectedProvider) return;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type S3Config,
|
||||
formatLastSync,
|
||||
getSyncDotColor,
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import {
|
||||
CloudSyncManager,
|
||||
@@ -181,13 +182,13 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
const hasAnyConnectedProvider = useMemo(() => {
|
||||
return (Object.values(state.providers) as ProviderConnection[]).some(
|
||||
(p) => p.status === 'connected' || p.status === 'syncing'
|
||||
(p) => isProviderReadyForSync(p)
|
||||
);
|
||||
}, [state.providers]);
|
||||
|
||||
const connectedProviderCount = useMemo(() => {
|
||||
return (Object.values(state.providers) as ProviderConnection[]).filter(
|
||||
(p) => p.status === 'connected' || p.status === 'syncing'
|
||||
(p) => isProviderReadyForSync(p)
|
||||
).length;
|
||||
}, [state.providers]);
|
||||
|
||||
@@ -519,7 +520,7 @@ export const useProviderStatus = (provider: CloudProvider) => {
|
||||
|
||||
return {
|
||||
...connection,
|
||||
isConnected: connection.status === 'connected',
|
||||
isConnected: isProviderReadyForSync(connection),
|
||||
isSyncing: connection.status === 'syncing',
|
||||
hasError: connection.status === 'error',
|
||||
dotColor: getSyncDotColor(connection.status),
|
||||
|
||||
@@ -110,14 +110,15 @@ const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boo
|
||||
serializeTerminalSettings(a) === serializeTerminalSettings(b);
|
||||
|
||||
const applyThemeTokens = (
|
||||
theme: 'light' | 'dark',
|
||||
themeSource: 'light' | 'dark' | 'system',
|
||||
resolvedTheme: 'light' | 'dark',
|
||||
tokens: UiThemeTokens,
|
||||
accentMode: 'theme' | 'custom',
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
root.classList.add(resolvedTheme);
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
@@ -126,7 +127,7 @@ const applyThemeTokens = (
|
||||
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
|
||||
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
|
||||
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
|
||||
const computedAccentForeground = theme === 'dark'
|
||||
const computedAccentForeground = resolvedTheme === 'dark'
|
||||
? '220 40% 96%'
|
||||
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
|
||||
|
||||
@@ -145,7 +146,7 @@ const applyThemeTokens = (
|
||||
root.style.setProperty('--ring', accentToken);
|
||||
|
||||
// Sync with native window title bar (Electron)
|
||||
netcattyBridge.get()?.setTheme?.(theme);
|
||||
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
|
||||
};
|
||||
|
||||
@@ -323,7 +324,7 @@ export const useSettingsState = () => {
|
||||
|
||||
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
|
||||
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
|
||||
applyThemeTokens(effective, tokens, nextAccentMode, nextAccent);
|
||||
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
const syncCustomCssFromStorage = useCallback(() => {
|
||||
@@ -333,7 +334,7 @@ export const useSettingsState = () => {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(resolvedTheme, tokens, accentMode, customAccent);
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
|
||||
@@ -36,7 +36,15 @@ export const useSftpState = (
|
||||
identities: Identity[],
|
||||
options?: SftpStateOptions
|
||||
) => {
|
||||
const tabsState = useSftpTabsState();
|
||||
const createPane = useCallback(
|
||||
(id?: string, showHiddenFiles = options?.defaultShowHiddenFiles ?? false) =>
|
||||
createEmptyPane(id, showHiddenFiles),
|
||||
[options?.defaultShowHiddenFiles],
|
||||
);
|
||||
|
||||
const tabsState = useSftpTabsState({
|
||||
defaultShowHiddenFiles: options?.defaultShowHiddenFiles,
|
||||
});
|
||||
const {
|
||||
leftTabs,
|
||||
rightTabs,
|
||||
@@ -49,6 +57,7 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
selectTab,
|
||||
@@ -143,7 +152,7 @@ export const useSftpState = (
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
createEmptyPane: createPane,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -205,6 +214,13 @@ export const useSftpState = (
|
||||
[clearCacheForConnection, getActivePane, navigateTo, updateActiveTab],
|
||||
);
|
||||
|
||||
const setShowHiddenFiles = useCallback(
|
||||
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
|
||||
setTabShowHiddenFiles(side, tabId, showHiddenFiles);
|
||||
},
|
||||
[setTabShowHiddenFiles],
|
||||
);
|
||||
|
||||
const {
|
||||
transfers,
|
||||
conflicts,
|
||||
@@ -270,6 +286,7 @@ export const useSftpState = (
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
setShowHiddenFiles,
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
@@ -315,6 +332,7 @@ export const useSftpState = (
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
setShowHiddenFiles,
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
@@ -364,6 +382,8 @@ export const useSftpState = (
|
||||
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
|
||||
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
|
||||
methodsRef.current.setFilenameEncoding(...args),
|
||||
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
|
||||
methodsRef.current.setShowHiddenFiles(...args),
|
||||
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
|
||||
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
|
||||
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// Check for updates at most once per hour
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
// Delay startup check to avoid slowing down app launch
|
||||
const STARTUP_CHECK_DELAY_MS = 5000;
|
||||
// Delay startup check to avoid slowing down app launch.
|
||||
// 8s gives electron-updater's startAutoCheck(5000) time to emit
|
||||
// 'update-available' first. The `onUpdateAvailable` handler also cancels
|
||||
// any pending startup timeout, so even on slow networks where the event
|
||||
// arrives after 8s the duplicate check is avoided.
|
||||
const STARTUP_CHECK_DELAY_MS = 8000;
|
||||
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
|
||||
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
@@ -19,6 +23,10 @@ const debugLog = (...args: unknown[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
export type ManualCheckStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'error';
|
||||
|
||||
export interface UpdateState {
|
||||
isChecking: boolean;
|
||||
hasUpdate: boolean;
|
||||
@@ -26,6 +34,12 @@ export interface UpdateState {
|
||||
latestRelease: ReleaseInfo | null;
|
||||
error: string | null;
|
||||
lastCheckedAt: number | null;
|
||||
// Auto-download state — driven by electron-updater IPC events
|
||||
autoDownloadStatus: AutoDownloadStatus;
|
||||
downloadPercent: number;
|
||||
downloadError: string | null;
|
||||
/** Manual check state — driven by user clicking "Check for Updates" */
|
||||
manualCheckStatus: ManualCheckStatus;
|
||||
}
|
||||
|
||||
export interface UseUpdateCheckResult {
|
||||
@@ -33,6 +47,7 @@ export interface UseUpdateCheckResult {
|
||||
checkNow: () => Promise<UpdateCheckResult | null>;
|
||||
dismissUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
installUpdate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,11 +64,44 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
latestRelease: null,
|
||||
error: null,
|
||||
lastCheckedAt: null,
|
||||
autoDownloadStatus: 'idle',
|
||||
downloadPercent: 0,
|
||||
downloadError: null,
|
||||
manualCheckStatus: 'idle',
|
||||
});
|
||||
|
||||
const hasCheckedOnStartupRef = useRef(false);
|
||||
const isCheckingRef = useRef(false);
|
||||
const startupCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Track current version in a ref to avoid stale closure in checkNow
|
||||
const currentVersionRef = useRef(updateState.currentVersion);
|
||||
// Track autoDownloadStatus in a ref so checkNow always reads the latest value
|
||||
const autoDownloadStatusRef = useRef<AutoDownloadStatus>('idle');
|
||||
// Timer ref for auto-resetting manualCheckStatus='up-to-date' back to 'idle'
|
||||
const manualCheckResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Flag: true when we suppressed auto-download because the version was dismissed.
|
||||
// Used to distinguish "idle because dismissed" from "idle because not hydrated yet"
|
||||
// in the progress/downloaded/error callbacks.
|
||||
const dismissedAutoDownloadRef = useRef(false);
|
||||
|
||||
// Keep currentVersionRef in sync so checkNow always reads the latest version
|
||||
useEffect(() => {
|
||||
currentVersionRef.current = updateState.currentVersion;
|
||||
}, [updateState.currentVersion]);
|
||||
|
||||
// Keep autoDownloadStatusRef in sync so checkNow always reads the latest download state
|
||||
useEffect(() => {
|
||||
autoDownloadStatusRef.current = updateState.autoDownloadStatus;
|
||||
}, [updateState.autoDownloadStatus]);
|
||||
|
||||
// Cleanup: clear any pending manualCheckStatus reset timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (manualCheckResetTimeoutRef.current) {
|
||||
clearTimeout(manualCheckResetTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get current app version
|
||||
useEffect(() => {
|
||||
@@ -71,6 +119,136 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
void loadVersion();
|
||||
}, []);
|
||||
|
||||
// Hydrate auto-download status from the main process so windows opened
|
||||
// after the download started (e.g. Settings) immediately reflect the
|
||||
// current state instead of showing stale 'idle'.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getUpdateStatus?.().then((snapshot) => {
|
||||
if (!snapshot || snapshot.status === 'idle') return;
|
||||
|
||||
// Respect dismissed versions: if the user dismissed this release,
|
||||
// don't surface download progress/ready state in late-opening windows.
|
||||
// Also set the dismissed ref so subsequent IPC events are suppressed.
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
if (snapshot.version && snapshot.version === dismissedVersion) {
|
||||
dismissedAutoDownloadRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdateState((prev) => {
|
||||
// Don't overwrite if the renderer already has a newer state
|
||||
if (prev.autoDownloadStatus !== 'idle') return prev;
|
||||
return {
|
||||
...prev,
|
||||
autoDownloadStatus: snapshot.status,
|
||||
downloadPercent: snapshot.percent,
|
||||
downloadError: snapshot.error,
|
||||
// Use snapshot version if no release data or if versions differ
|
||||
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
|
||||
version: snapshot.version,
|
||||
tagName: `v${snapshot.version}`,
|
||||
name: `v${snapshot.version}`,
|
||||
body: '',
|
||||
htmlUrl: '',
|
||||
publishedAt: new Date().toISOString(),
|
||||
assets: [],
|
||||
} : prev.latestRelease) : prev.latestRelease,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Subscribe to electron-updater auto-download IPC events.
|
||||
// These fire automatically when autoDownload=true in the main process.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
// When electron-updater confirms no update in its feed, don't write
|
||||
// STORAGE_KEY_UPDATE_LAST_CHECK — that would throttle the GitHub API
|
||||
// fallback for an hour. Let performCheck write it on success so the
|
||||
// GitHub check can still discover releases not yet in the updater feed.
|
||||
const cleanupNotAvailable = bridge?.onUpdateNotAvailable?.(() => {
|
||||
// No-op for now — the GitHub fallback will handle lastCheckedAt.
|
||||
});
|
||||
|
||||
const cleanupAvailable = bridge?.onUpdateAvailable?.((info) => {
|
||||
// Cancel any pending startup GitHub API check — electron-updater is
|
||||
// now authoritative and we don't want a duplicate toast.
|
||||
if (startupCheckTimeoutRef.current) {
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
startupCheckTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Check if this version was dismissed by the user
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
const isDismissed = dismissedVersion === info.version;
|
||||
if (isDismissed) {
|
||||
dismissedAutoDownloadRef.current = true;
|
||||
}
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
hasUpdate: !isDismissed,
|
||||
// Only transition to 'downloading' if the user hasn't dismissed this
|
||||
// version — otherwise leave the status at 'idle' so no download
|
||||
// progress/ready toast appears for a release they don't want.
|
||||
autoDownloadStatus: isDismissed ? prev.autoDownloadStatus : 'downloading',
|
||||
downloadPercent: isDismissed ? prev.downloadPercent : 0,
|
||||
downloadError: isDismissed ? prev.downloadError : null,
|
||||
// Use electron-updater's version if GitHub API hasn't resolved yet or
|
||||
// if the updater reports a different version than the cached release.
|
||||
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
|
||||
version: info.version,
|
||||
tagName: `v${info.version}`,
|
||||
name: `v${info.version}`,
|
||||
body: info.releaseNotes || '',
|
||||
htmlUrl: '',
|
||||
publishedAt: info.releaseDate || new Date().toISOString(),
|
||||
assets: [],
|
||||
} : prev.latestRelease,
|
||||
}));
|
||||
});
|
||||
|
||||
const cleanupProgress = bridge?.onUpdateDownloadProgress?.((p) => {
|
||||
// If we suppressed the download for a dismissed version, ignore progress.
|
||||
if (dismissedAutoDownloadRef.current) return;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'downloading',
|
||||
downloadPercent: Math.round(p.percent),
|
||||
}));
|
||||
});
|
||||
|
||||
const cleanupDownloaded = bridge?.onUpdateDownloaded?.(() => {
|
||||
// If the download was for a dismissed version, don't transition to
|
||||
// 'ready' — that would trigger the "Update ready" toast.
|
||||
if (dismissedAutoDownloadRef.current) return;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'ready',
|
||||
downloadPercent: 100,
|
||||
}));
|
||||
});
|
||||
|
||||
const cleanupError = bridge?.onUpdateError?.((payload) => {
|
||||
// If we suppressed the download for a dismissed version, ignore errors.
|
||||
if (dismissedAutoDownloadRef.current) return;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'error',
|
||||
downloadError: payload.error,
|
||||
}));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanupNotAvailable?.();
|
||||
cleanupAvailable?.();
|
||||
cleanupProgress?.();
|
||||
cleanupDownloaded?.();
|
||||
cleanupError?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
|
||||
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
|
||||
|
||||
@@ -119,8 +297,16 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
debugLog('Latest release version:', result.latestRelease?.version);
|
||||
const now = Date.now();
|
||||
|
||||
// Save last check time
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
|
||||
// Only advance last-check time and cache release on successful checks.
|
||||
// Failed checks (result.error set, no latestRelease) must not update
|
||||
// the timestamp — otherwise stale cached release data persists for an
|
||||
// hour while the throttle prevents re-checking.
|
||||
if (!result.error) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
|
||||
if (result.latestRelease) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UPDATE_LATEST_RELEASE, JSON.stringify(result.latestRelease));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this version was dismissed
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
@@ -156,11 +342,121 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkNow = useCallback(async () => {
|
||||
// In demo mode, use fake version to allow checking
|
||||
const version = IS_UPDATE_DEMO_MODE ? '0.0.1' : updateState.currentVersion;
|
||||
return performCheck(version);
|
||||
}, [performCheck, updateState.currentVersion]);
|
||||
const checkNow = useCallback(async (): Promise<UpdateCheckResult | null> => {
|
||||
// Prevent concurrent checks (performCheck owns isCheckingRef)
|
||||
if (isCheckingRef.current) {
|
||||
debugLog('checkNow: already checking, skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cancel any pending startup auto-check to avoid racing with
|
||||
// electron-updater's startAutoCheck — concurrent checkForUpdates()
|
||||
// calls are rejected by electron-updater and would surface a false error.
|
||||
if (startupCheckTimeoutRef.current) {
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
startupCheckTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear any pending "up-to-date" auto-reset timer
|
||||
if (manualCheckResetTimeoutRef.current) {
|
||||
clearTimeout(manualCheckResetTimeoutRef.current);
|
||||
manualCheckResetTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Reset dismissed flag so a manual retry can surface download events again
|
||||
dismissedAutoDownloadRef.current = false;
|
||||
|
||||
// Immediately reflect 'checking' in the UI; reset download error so the user can retry
|
||||
setUpdateState((prev) => {
|
||||
// Eagerly sync the ref so the checkForUpdate gate below reads the updated value
|
||||
if (prev.autoDownloadStatus === 'error') {
|
||||
autoDownloadStatusRef.current = 'idle';
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
manualCheckStatus: 'checking',
|
||||
error: null,
|
||||
// P2: reset download error state so auto-download can retry on next available update
|
||||
autoDownloadStatus: prev.autoDownloadStatus === 'error' ? 'idle' : prev.autoDownloadStatus,
|
||||
downloadError: prev.autoDownloadStatus === 'error' ? null : prev.downloadError,
|
||||
};
|
||||
});
|
||||
|
||||
// Skip check for dev/invalid builds (demo mode overrides to '0.0.1' inside performCheck)
|
||||
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersionRef.current;
|
||||
if (!effectiveVersion || effectiveVersion === '0.0.0') {
|
||||
// Dev/invalid build — can't determine update status, reset to idle
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
manualCheckStatus: 'idle',
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Delegate to performCheck (GitHub API) — completely independent of
|
||||
// electron-updater's startAutoCheck() in the main process.
|
||||
// performCheck sets isCheckingRef, isChecking, hasUpdate, latestRelease.
|
||||
const result = await performCheck(effectiveVersion);
|
||||
|
||||
// Determine manual check status. performCheck already suppressed dismissed
|
||||
// versions in state (hasUpdate=false), so we must respect that here too —
|
||||
// otherwise a dismissed release would be reported as 'available' and could
|
||||
// trigger a background download via checkForUpdate below.
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
const isAvailable = result !== null && !result.error && result.hasUpdate &&
|
||||
result.latestRelease?.version !== dismissedVersion;
|
||||
const nextStatus: ManualCheckStatus =
|
||||
result === null || result.error ? 'error' : isAvailable ? 'available' : 'up-to-date';
|
||||
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
manualCheckStatus: nextStatus,
|
||||
}));
|
||||
|
||||
if (nextStatus === 'up-to-date') {
|
||||
// Auto-reset "up-to-date" badge back to idle after 5s
|
||||
manualCheckResetTimeoutRef.current = setTimeout(() => {
|
||||
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
|
||||
}, 5000);
|
||||
} else if ((nextStatus === 'available' || nextStatus === 'error') && autoDownloadStatusRef.current === 'idle') {
|
||||
// Trigger electron-updater as a fallback. This covers two cases:
|
||||
// 1. 'available': GitHub found an update but electron-updater hasn't
|
||||
// started a download yet — kick it off.
|
||||
// 2. 'error': GitHub API failed (blocked/rate-limited), but the
|
||||
// electron-updater feed may still be reachable. Without this,
|
||||
// environments where api.github.com is blocked would never attempt
|
||||
// the auto-download path.
|
||||
void netcattyBridge.get()?.checkForUpdate?.().then((res) => {
|
||||
if (res?.error && res?.supported !== false) {
|
||||
// Surface actual download-feed errors; unsupported platforms
|
||||
// (res.supported === false) should keep autoDownloadStatus at
|
||||
// 'idle' so the manual download link shows.
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'error',
|
||||
downloadError: res.error,
|
||||
}));
|
||||
} else if (res?.checking) {
|
||||
// Another check is already in flight — don't change status; the
|
||||
// in-flight check will resolve via IPC events.
|
||||
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
|
||||
// GitHub API failed but electron-updater says no update available.
|
||||
// Clear the error status so Settings doesn't stay stuck in error state.
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
manualCheckStatus: 'up-to-date',
|
||||
}));
|
||||
manualCheckResetTimeoutRef.current = setTimeout(() => {
|
||||
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
|
||||
}, 5000);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Bridge unavailable — ignore; the manual download link remains visible
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [performCheck]);
|
||||
|
||||
const dismissUpdate = useCallback(() => {
|
||||
if (updateState.latestRelease?.version) {
|
||||
@@ -189,6 +485,10 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, [updateState.latestRelease]);
|
||||
|
||||
const installUpdate = useCallback(() => {
|
||||
netcattyBridge.get()?.installUpdate?.();
|
||||
}, []);
|
||||
|
||||
// Startup check with delay - runs once on mount
|
||||
useEffect(() => {
|
||||
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
|
||||
@@ -238,13 +538,60 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
// Hydrate cached release info so late-opening windows show the result
|
||||
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
|
||||
if (cachedRelease) {
|
||||
try {
|
||||
const release = JSON.parse(cachedRelease) as ReleaseInfo;
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
const isNewer = updateState.currentVersion.localeCompare(release.version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
|
||||
const showUpdate = isNewer && release.version !== dismissedVersion;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
latestRelease: prev.latestRelease ?? release,
|
||||
hasUpdate: prev.hasUpdate || showUpdate,
|
||||
lastCheckedAt: lastCheck,
|
||||
}));
|
||||
} catch {
|
||||
// Ignore corrupted cache
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
debugLog('Starting delayed update check for version:', updateState.currentVersion);
|
||||
|
||||
startupCheckTimeoutRef.current = setTimeout(() => {
|
||||
startupCheckTimeoutRef.current = setTimeout(async () => {
|
||||
// If electron-updater's auto-check already started a download, skip the
|
||||
// redundant GitHub API check to avoid duplicate toast notifications.
|
||||
if (autoDownloadStatusRef.current !== 'idle') {
|
||||
debugLog('Skipping startup check — auto-download already active');
|
||||
return;
|
||||
}
|
||||
// If the main process check is still in flight, reschedule the
|
||||
// fallback instead of permanently skipping it — the auto-check may
|
||||
// fail silently (check-phase errors aren't broadcast to the renderer).
|
||||
try {
|
||||
const snapshot = await netcattyBridge.get()?.getUpdateStatus?.();
|
||||
if (snapshot?.isChecking) {
|
||||
debugLog('Main process check still in flight — rescheduling fallback');
|
||||
startupCheckTimeoutRef.current = setTimeout(async () => {
|
||||
if (autoDownloadStatusRef.current !== 'idle') return;
|
||||
// Re-check if the main process check is still running to avoid
|
||||
// duplicate notifications on very slow networks.
|
||||
try {
|
||||
const snap = await netcattyBridge.get()?.getUpdateStatus?.();
|
||||
if (snap?.isChecking || (snap?.status && snap.status !== 'idle')) return;
|
||||
} catch { /* fall through */ }
|
||||
debugLog('=== Rescheduled fallback check triggered ===');
|
||||
void performCheck(updateState.currentVersion);
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Bridge unavailable — fall through to GitHub check
|
||||
}
|
||||
debugLog('=== Delayed check triggered ===');
|
||||
void performCheck(updateState.currentVersion);
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
@@ -261,5 +608,6 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
checkNow,
|
||||
dismissUpdate,
|
||||
openReleasePage,
|
||||
installUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ type ExportableVaultData = {
|
||||
identities?: Identity[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
knownHosts?: KnownHost[];
|
||||
};
|
||||
|
||||
@@ -557,9 +558,10 @@ export const useVaultState = () => {
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
}),
|
||||
[hosts, keys, identities, snippets, customGroups, knownHosts],
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
|
||||
);
|
||||
|
||||
const importData = useCallback(
|
||||
@@ -569,6 +571,7 @@ export const useVaultState = () => {
|
||||
if (payload.identities) updateIdentities(payload.identities);
|
||||
if (payload.snippets) updateSnippets(payload.snippets);
|
||||
if (payload.customGroups) updateCustomGroups(payload.customGroups);
|
||||
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
|
||||
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
|
||||
},
|
||||
[
|
||||
@@ -577,6 +580,7 @@ export const useVaultState = () => {
|
||||
updateIdentities,
|
||||
updateSnippets,
|
||||
updateCustomGroups,
|
||||
updateSnippetPackages,
|
||||
updateKnownHosts,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../domain/credentials';
|
||||
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -681,10 +681,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
const disconnectOtherProviders = async (current: CloudProvider) => {
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
const isActive = (status: string) => status === 'connected' || status === 'syncing';
|
||||
for (const provider of providers) {
|
||||
if (provider === current) continue;
|
||||
if (isActive(sync.providers[provider].status)) {
|
||||
if (isProviderReadyForSync(sync.providers[provider])) {
|
||||
await sync.disconnectProvider(provider);
|
||||
}
|
||||
}
|
||||
@@ -1061,13 +1060,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="github"
|
||||
name="GitHub Gist"
|
||||
icon={<Github size={24} />}
|
||||
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.github)}
|
||||
isSyncing={sync.providers.github.status === 'syncing'}
|
||||
isConnecting={sync.providers.github.status === 'connecting'}
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.github)}
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
@@ -1077,13 +1076,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="google"
|
||||
name="Google Drive"
|
||||
icon={<GoogleDriveIcon className="w-6 h-6" />}
|
||||
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.google)}
|
||||
isSyncing={sync.providers.google.status === 'syncing'}
|
||||
isConnecting={sync.providers.google.status === 'connecting'}
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
|
||||
onConnect={handleConnectGoogle}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
onSync={() => handleSync('google')}
|
||||
@@ -1093,13 +1092,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="onedrive"
|
||||
name="Microsoft OneDrive"
|
||||
icon={<OneDriveIcon className="w-6 h-6" />}
|
||||
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.onedrive)}
|
||||
isSyncing={sync.providers.onedrive.status === 'syncing'}
|
||||
isConnecting={sync.providers.onedrive.status === 'connecting'}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
onSync={() => handleSync('onedrive')}
|
||||
@@ -1109,13 +1108,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="webdav"
|
||||
name={t('cloudSync.provider.webdav')}
|
||||
icon={<Server size={24} />}
|
||||
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.webdav)}
|
||||
isSyncing={sync.providers.webdav.status === 'syncing'}
|
||||
isConnecting={sync.providers.webdav.status === 'connecting'}
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.webdav)}
|
||||
onEdit={openWebdavDialog}
|
||||
onConnect={openWebdavDialog}
|
||||
onDisconnect={() => sync.disconnectProvider('webdav')}
|
||||
@@ -1126,13 +1125,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="s3"
|
||||
name={t('cloudSync.provider.s3')}
|
||||
icon={<Database size={24} />}
|
||||
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.s3)}
|
||||
isSyncing={sync.providers.s3.status === 'syncing'}
|
||||
isConnecting={sync.providers.s3.status === 'connecting'}
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.s3)}
|
||||
onEdit={openS3Dialog}
|
||||
onConnect={openS3Dialog}
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
|
||||
redhat: "/distro/redhat.svg",
|
||||
oracle: "/distro/oracle.svg",
|
||||
kali: "/distro/kali.svg",
|
||||
almalinux: "/distro/almalinux.svg",
|
||||
};
|
||||
|
||||
export const DISTRO_COLORS: Record<string, string> = {
|
||||
@@ -32,6 +33,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
redhat: "bg-[#EE0000]",
|
||||
oracle: "bg-[#C74634]",
|
||||
kali: "bg-[#0F6DB3]",
|
||||
almalinux: "bg-[#173B66]",
|
||||
default: "bg-slate-600",
|
||||
};
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const {
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
@@ -204,6 +205,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
currentPathRef,
|
||||
files,
|
||||
loading,
|
||||
setLoading,
|
||||
@@ -384,6 +386,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
dismissTask,
|
||||
} = useSftpModalTransfers({
|
||||
currentPath,
|
||||
currentPathRef,
|
||||
isLocalSession,
|
||||
joinPath: joinPathForSession,
|
||||
ensureSftp,
|
||||
@@ -475,8 +478,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
initialUploadTriggeredRef.current = true;
|
||||
|
||||
// Trigger upload with full DropEntry data (preserves directory structure)
|
||||
handleUploadEntries(initialEntriesToUpload);
|
||||
}, [initialEntriesToUpload, open, loading, handleUploadEntries]);
|
||||
void handleUploadEntries(initialEntriesToUpload);
|
||||
}, [handleUploadEntries, initialEntriesToUpload, loading, open]);
|
||||
|
||||
// Display files with parent entry (like SftpView)
|
||||
const displayFiles = useMemo(() => {
|
||||
@@ -541,6 +544,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
|
||||
return parentEntry ? [parentEntry, ...sorted] : sorted;
|
||||
}, [displayFiles, sortField, sortOrder]);
|
||||
const hasFiles = files.length > 0;
|
||||
const hasDisplayFiles = sortedFiles.length > 0;
|
||||
const {
|
||||
fileListRef,
|
||||
handleFileListScroll,
|
||||
@@ -685,6 +690,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onCreateFile={handleCreateFile}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
onToggleShowHiddenFiles={() =>
|
||||
setSftpShowHiddenFiles(!sftpShowHiddenFiles)
|
||||
}
|
||||
onUpdateHost={onUpdateHost}
|
||||
onNavigateToBookmark={(path) => setCurrentPath(path)}
|
||||
/>
|
||||
@@ -693,7 +702,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
t={t}
|
||||
currentPath={currentPath}
|
||||
isLocalSession={isLocalSession}
|
||||
files={files}
|
||||
hasFiles={hasFiles}
|
||||
hasDisplayFiles={hasDisplayFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
dragActive={dragActive}
|
||||
loading={loading}
|
||||
|
||||
@@ -4,7 +4,7 @@ import AppLogo from "./AppLogo";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { SettingsTabContent } from "./settings/settings-ui";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -63,13 +63,18 @@ const ActionRow: React.FC<{
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function SettingsApplicationTab() {
|
||||
interface SettingsApplicationTabProps {
|
||||
updateState: UpdateState;
|
||||
checkNow: UseUpdateCheckResult['checkNow'];
|
||||
openReleasePage: UseUpdateCheckResult['openReleasePage'];
|
||||
installUpdate: UseUpdateCheckResult['installUpdate'];
|
||||
}
|
||||
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
|
||||
const { t } = useI18n();
|
||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||
const { updateState, checkNow, openReleasePage } = useUpdateCheck();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
|
||||
const [hasAutoChecked, setHasAutoChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -93,19 +98,6 @@ export default function SettingsApplicationTab() {
|
||||
const isUpdateDemoMode = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
|
||||
// Auto check for updates when entering this page
|
||||
useEffect(() => {
|
||||
if (hasAutoChecked) return;
|
||||
if (updateState.isChecking) return;
|
||||
|
||||
// In demo mode or when we have a valid version, auto-check
|
||||
const canCheck = isUpdateDemoMode || (appInfo.version && appInfo.version !== '0.0.0');
|
||||
if (!canCheck) return;
|
||||
|
||||
setHasAutoChecked(true);
|
||||
void checkNow();
|
||||
}, [hasAutoChecked, updateState.isChecking, isUpdateDemoMode, appInfo.version, checkNow]);
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
// In demo mode, allow checking even for dev builds
|
||||
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
|
||||
@@ -124,8 +116,9 @@ export default function SettingsApplicationTab() {
|
||||
t('update.available.message', { version: result.latestRelease.version }),
|
||||
t('update.available.title')
|
||||
);
|
||||
// Open the release page
|
||||
openReleasePage();
|
||||
// Don't auto-open the release page here — checkNow() already triggers
|
||||
// electron-updater on supported platforms, and the Settings > System tab
|
||||
// shows a "Manual Download" link on unsupported platforms.
|
||||
} else if (result) {
|
||||
setLastCheckResult('upToDate');
|
||||
toast.success(
|
||||
@@ -154,18 +147,25 @@ export default function SettingsApplicationTab() {
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{appInfo.version ? appInfo.version : " "}
|
||||
</span>
|
||||
{/* Update available badge - inline with version */}
|
||||
{updateState.hasUpdate && updateState.latestRelease && (
|
||||
{/* Update badge - reflects auto-download state */}
|
||||
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
|
||||
<button
|
||||
onClick={() => void openReleasePage()}
|
||||
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
"bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
|
||||
"hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer"
|
||||
updateState.autoDownloadStatus === 'ready'
|
||||
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800"
|
||||
: "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800",
|
||||
"transition-colors cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<ArrowUpCircle size={12} />
|
||||
v{updateState.latestRelease.version} {t('update.downloadNow')}
|
||||
v{updateState.latestRelease.version}{' '}
|
||||
{updateState.autoDownloadStatus === 'ready'
|
||||
? t('update.restartNow')
|
||||
: updateState.autoDownloadStatus === 'downloading'
|
||||
? `${updateState.downloadPercent}%`
|
||||
: t('update.downloadNow')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
@@ -33,6 +34,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
@@ -53,8 +55,8 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
);
|
||||
|
||||
const vault = useMemo(
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, knownHosts }),
|
||||
[hosts, keys, identities, snippets, customGroups, knownHosts],
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts }),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -71,6 +73,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck();
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
|
||||
@@ -165,7 +168,14 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
</div>
|
||||
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
|
||||
{mountedTabs.has("application") && <SettingsApplicationTab />}
|
||||
{mountedTabs.has("application") && (
|
||||
<SettingsApplicationTab
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
openReleasePage={openReleasePage}
|
||||
installUpdate={installUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("appearance") && (
|
||||
<SettingsAppearanceTab
|
||||
@@ -237,6 +247,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
openReleasePage={openReleasePage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
const sftpOptions = useMemo(() => ({
|
||||
...fileWatchHandlers,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload]);
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
|
||||
@@ -107,7 +108,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
isActive,
|
||||
showHiddenFiles: sftpShowHiddenFiles,
|
||||
});
|
||||
|
||||
// Subscribe to focused side for visual indicator
|
||||
@@ -118,6 +118,14 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
}, []);
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
|
||||
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.id === paneId);
|
||||
if (!pane) return;
|
||||
|
||||
sftpRef.current.setShowHiddenFiles(side, paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
|
||||
// Using useLayoutEffect to sync before paint
|
||||
useLayoutEffect(() => {
|
||||
@@ -225,7 +233,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
dragCallbacks={dragCallbacks}
|
||||
leftCallbacks={leftCallbacks}
|
||||
rightCallbacks={rightCallbacks}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -277,6 +284,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
pane={pane}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
|
||||
/>
|
||||
</SftpPaneWrapper>
|
||||
))}
|
||||
@@ -333,6 +341,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
pane={pane}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
|
||||
/>
|
||||
</SftpPaneWrapper>
|
||||
))}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import type { CloudProvider } from '../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider } from '../domain/sync';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -122,12 +122,11 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
// Get connected provider (include syncing status as it's still connected)
|
||||
const getConnectedProvider = (): CloudProvider | null => {
|
||||
const isProviderActive = (status: string) => status === 'connected' || status === 'syncing';
|
||||
if (isProviderActive(sync.providers.github.status)) return 'github';
|
||||
if (isProviderActive(sync.providers.google.status)) return 'google';
|
||||
if (isProviderActive(sync.providers.onedrive.status)) return 'onedrive';
|
||||
if (isProviderActive(sync.providers.webdav.status)) return 'webdav';
|
||||
if (isProviderActive(sync.providers.s3.status)) return 's3';
|
||||
if (isProviderReadyForSync(sync.providers.github)) return 'github';
|
||||
if (isProviderReadyForSync(sync.providers.google)) return 'google';
|
||||
if (isProviderReadyForSync(sync.providers.onedrive)) return 'onedrive';
|
||||
if (isProviderReadyForSync(sync.providers.webdav)) return 'webdav';
|
||||
if (isProviderReadyForSync(sync.providers.s3)) return 's3';
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -136,9 +135,9 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
// Determine overall status for the button indicator
|
||||
const getOverallStatus = (): StatusIndicatorProps['status'] => {
|
||||
if (sync.isSyncing) return 'syncing';
|
||||
if (sync.lastError) return 'error';
|
||||
if (sync.hasAnyConnectedProvider) return 'synced';
|
||||
if (sync.overallSyncStatus === 'syncing') return 'syncing';
|
||||
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
|
||||
if (sync.overallSyncStatus === 'synced') return 'synced';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
TerminalSettings,
|
||||
KeyBinding,
|
||||
} from "../types";
|
||||
import {
|
||||
shouldEnableNativeUserInputAutoScroll,
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
@@ -221,6 +225,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
isVisibleRef.current = isVisible;
|
||||
const pendingOutputScrollRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (xtermRuntimeRef.current) {
|
||||
@@ -416,8 +423,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
startupCommand,
|
||||
terminalSettings,
|
||||
terminalSettingsRef,
|
||||
terminalBackend,
|
||||
serialConfig,
|
||||
isVisibleRef,
|
||||
pendingOutputScrollRef,
|
||||
sessionRef,
|
||||
hasConnectedRef,
|
||||
hasRunStartupCommandRef,
|
||||
@@ -454,6 +464,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
let disposed = false;
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
pendingOutputScrollRef.current = false;
|
||||
setProgressLogs([]);
|
||||
setShowLogs(false);
|
||||
setIsCancelling(false);
|
||||
@@ -703,7 +714,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalSettings.drawBoldInBrightColors;
|
||||
termRef.current.options.minimumContrastRatio =
|
||||
terminalSettings.minimumContrastRatio;
|
||||
termRef.current.options.scrollOnUserInput = terminalSettings.scrollOnInput;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
@@ -732,10 +744,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
const timer = setTimeout(() => safeFit(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (!isVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit();
|
||||
if (pendingOutputScrollRef.current) {
|
||||
termRef.current?.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
termRef.current?.scrollToBottom();
|
||||
});
|
||||
}
|
||||
pendingOutputScrollRef.current = false;
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -964,6 +986,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = (data: string) => {
|
||||
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
|
||||
termRef.current.scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
sessionRef,
|
||||
@@ -975,7 +1003,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const handleSnippetClick = (cmd: string) => {
|
||||
if (sessionRef.current) {
|
||||
terminalBackend.writeToSession(sessionRef.current, `${cmd}\r`);
|
||||
const payload = `${cmd}\r`;
|
||||
terminalBackend.writeToSession(sessionRef.current, payload);
|
||||
scrollToBottomAfterProgrammaticInput(payload);
|
||||
setIsScriptsOpen(false);
|
||||
termRef.current?.focus();
|
||||
return;
|
||||
@@ -1142,6 +1172,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const pathsText = paths.join(' ');
|
||||
// Write the paths to the terminal
|
||||
terminalBackend.writeToSession(sessionRef.current, pathsText);
|
||||
scrollToBottomAfterProgrammaticInput(pathsText);
|
||||
termRef.current.focus();
|
||||
}
|
||||
} else {
|
||||
@@ -1699,6 +1730,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (sessionRef.current) {
|
||||
const payload = text + '\r';
|
||||
terminalBackend.writeToSession(sessionRef.current, payload);
|
||||
scrollToBottomAfterProgrammaticInput(payload);
|
||||
onBroadcastInput?.(payload, sessionRef.current);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -6,15 +6,7 @@ import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
checkForUpdate,
|
||||
downloadUpdate,
|
||||
installUpdate,
|
||||
onDownloadProgress,
|
||||
onDownloaded,
|
||||
onError as onUpdateError,
|
||||
getReleasesUrl,
|
||||
} from "../../../infrastructure/services/updateService";
|
||||
import type { UpdateState } from '../../../application/state/useUpdateCheck';
|
||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
@@ -35,6 +27,22 @@ function formatBytes(bytes: number): string {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/** Returns a locale-agnostic relative time string for the given timestamp. */
|
||||
function formatLastChecked(
|
||||
timestamp: number | null,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (!timestamp) return '';
|
||||
const diffMs = Date.now() - timestamp;
|
||||
if (diffMs < 0) return t('settings.update.lastCheckedJustNow');
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return t('settings.update.lastCheckedJustNow');
|
||||
if (diffMins < 60)
|
||||
return t('settings.update.lastCheckedMinutesAgo').replace('{n}', String(diffMins));
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
return t('settings.update.lastCheckedHoursAgo').replace('{n}', String(diffHours));
|
||||
}
|
||||
|
||||
interface SettingsSystemTabProps {
|
||||
sessionLogsEnabled: boolean;
|
||||
setSessionLogsEnabled: (enabled: boolean) => void;
|
||||
@@ -47,6 +55,11 @@ interface SettingsSystemTabProps {
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
hotkeyRegistrationError: string | null;
|
||||
// Unified update state — from useUpdateCheck hook in SettingsPageContent
|
||||
updateState: UpdateState;
|
||||
checkNow: () => Promise<unknown>;
|
||||
installUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
@@ -61,6 +74,10 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
updateState,
|
||||
checkNow,
|
||||
installUpdate,
|
||||
openReleasePage,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
@@ -74,13 +91,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
|
||||
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
|
||||
|
||||
// Software Update state
|
||||
type UpdateStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'downloading' | 'ready' | 'error';
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
const [updateVersion, setUpdateVersion] = useState('');
|
||||
const [updatePercent, setUpdatePercent] = useState(0);
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
const [updateSupported, setUpdateSupported] = useState(true);
|
||||
const [appVersion, setAppVersion] = useState('');
|
||||
|
||||
// Load app version on mount
|
||||
@@ -93,63 +103,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Subscribe to auto-update events
|
||||
useEffect(() => {
|
||||
const cleanupProgress = onDownloadProgress((p) => {
|
||||
setUpdatePercent(Math.round(p.percent));
|
||||
});
|
||||
const cleanupDownloaded = onDownloaded(() => {
|
||||
setUpdateStatus('ready');
|
||||
});
|
||||
const cleanupError = onUpdateError((payload) => {
|
||||
setUpdateError(payload.error);
|
||||
setUpdateStatus('error');
|
||||
});
|
||||
return () => {
|
||||
cleanupProgress?.();
|
||||
cleanupDownloaded?.();
|
||||
cleanupError?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCheckForUpdate = useCallback(async () => {
|
||||
setUpdateStatus('checking');
|
||||
setUpdateError('');
|
||||
const result = await checkForUpdate();
|
||||
if (result.error) {
|
||||
setUpdateError(result.error);
|
||||
setUpdateSupported(result.supported !== false);
|
||||
setUpdateStatus('error');
|
||||
} else if (result.available && result.version) {
|
||||
setUpdateVersion(result.version);
|
||||
setUpdateSupported(result.supported !== false);
|
||||
setUpdateStatus('available');
|
||||
} else {
|
||||
setUpdateSupported(result.supported !== false);
|
||||
setUpdateStatus('up-to-date');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDownloadUpdate = useCallback(async () => {
|
||||
setUpdateStatus('downloading');
|
||||
setUpdatePercent(0);
|
||||
const result = await downloadUpdate();
|
||||
if (!result.success) {
|
||||
setUpdateError(result.error ?? t('settings.update.downloadError'));
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
// Success is handled by onDownloaded event
|
||||
}, [t]);
|
||||
|
||||
const handleInstallUpdate = useCallback(() => {
|
||||
installUpdate();
|
||||
}, []);
|
||||
|
||||
const handleOpenReleases = useCallback(() => {
|
||||
const url = updateVersion ? getReleasesUrl(updateVersion) : getReleasesUrl();
|
||||
netcattyBridge.get()?.openExternal?.(url);
|
||||
}, [updateVersion]);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getTempDirInfo) return;
|
||||
@@ -315,85 +268,99 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('settings.update.currentVersion')}
|
||||
</span>
|
||||
<span className="text-sm font-mono">{appVersion || '...'}</span>
|
||||
<span className="text-sm font-mono">
|
||||
{updateState.currentVersion || appVersion || '...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
{updateStatus === 'up-to-date' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('settings.update.upToDate')}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === 'available' && (
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
{t('settings.update.available').replace('{version}', updateVersion)}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === 'downloading' && (
|
||||
{/* Status message — priority: autoDownloadStatus > isChecking/manualCheckStatus */}
|
||||
{updateState.autoDownloadStatus === 'downloading' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.update.downloading').replace('{percent}', String(updatePercent))}
|
||||
{t('settings.update.downloading').replace('{percent}', String(updateState.downloadPercent))}
|
||||
</p>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${updatePercent}%` }}
|
||||
style={{ width: `${updateState.downloadPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{updateStatus === 'ready' && (
|
||||
{updateState.autoDownloadStatus === 'ready' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('settings.update.readyToInstall')}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === 'error' && (
|
||||
{updateState.autoDownloadStatus === 'error' && (
|
||||
<p className="text-sm text-destructive">
|
||||
{updateError || t('settings.update.error')}
|
||||
{updateState.downloadError || t('settings.update.error')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Manual fallback hint when auto-update not supported */}
|
||||
{!updateSupported && updateStatus !== 'idle' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.update.manualDownloadHint')}
|
||||
</p>
|
||||
{updateState.autoDownloadStatus === 'idle' && (
|
||||
<>
|
||||
{updateState.manualCheckStatus === 'up-to-date' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('settings.update.upToDate')}
|
||||
</p>
|
||||
)}
|
||||
{(updateState.manualCheckStatus === 'available' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
{t('settings.update.available').replace(
|
||||
'{version}',
|
||||
updateState.latestRelease?.version ?? ''
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{updateState.manualCheckStatus === 'error' && (
|
||||
<p className="text-sm text-destructive">
|
||||
{updateState.error || t('settings.update.error')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{(updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheckForUpdate}
|
||||
disabled={updateStatus === 'checking'}
|
||||
>
|
||||
<RefreshCw size={14} className={cn('mr-1.5', updateStatus === 'checking' && 'animate-spin')} />
|
||||
{updateStatus === 'checking' ? t('settings.update.checking') : t('settings.update.checkForUpdates')}
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus === 'checking' && (
|
||||
{/* Checking spinner — shown when isChecking OR manualCheckStatus=checking, but no active download */}
|
||||
{(updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') &&
|
||||
(updateState.isChecking || updateState.manualCheckStatus === 'checking') ? (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<RefreshCw size={14} className="mr-1.5 animate-spin" />
|
||||
{t('settings.update.checking')}
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus === 'available' && updateSupported && (
|
||||
<Button variant="default" size="sm" onClick={handleDownloadUpdate}>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{t('settings.update.download')}
|
||||
) : (updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') ? (
|
||||
/* Check button — shown in idle states and in error state (allows retry) */
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void checkNow()}
|
||||
>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('settings.update.checkForUpdates')}
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus === 'ready' && (
|
||||
<Button variant="default" size="sm" onClick={handleInstallUpdate}>
|
||||
) : null}
|
||||
|
||||
{/* Install button — shown when download is complete */}
|
||||
{updateState.autoDownloadStatus === 'ready' && (
|
||||
<Button variant="default" size="sm" onClick={installUpdate}>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t('settings.update.restartNow')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Manual fallback link — shown when unsupported, on error, or when update is available but unsupported */}
|
||||
{((updateStatus === 'error') || (updateStatus === 'available' && !updateSupported)) && (
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenReleases}>
|
||||
|
||||
{/* Open releases — shown on download error */}
|
||||
{updateState.autoDownloadStatus === 'error' && (
|
||||
<Button variant="ghost" size="sm" onClick={openReleasePage}>
|
||||
<ExternalLink size={14} className="mr-1.5" />
|
||||
{t('settings.update.manualDownload')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Open releases — shown when update found on unsupported platform, or on check error */}
|
||||
{updateState.autoDownloadStatus === 'idle' &&
|
||||
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
|
||||
<Button variant="ghost" size="sm" onClick={openReleasePage}>
|
||||
<ExternalLink size={14} className="mr-1.5" />
|
||||
{t('settings.update.manualDownload')}
|
||||
</Button>
|
||||
@@ -401,6 +368,13 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{updateState.lastCheckedAt && (
|
||||
<span>
|
||||
{t('settings.update.lastCheckedPrefix')}
|
||||
{formatLastChecked(updateState.lastCheckedAt, t)}
|
||||
{' '}
|
||||
</span>
|
||||
)}
|
||||
{t('settings.update.hint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,8 @@ interface SftpModalFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
files: RemoteFile[];
|
||||
hasFiles: boolean;
|
||||
hasDisplayFiles: boolean;
|
||||
selectedFiles: Set<string>;
|
||||
dragActive: boolean;
|
||||
loading: boolean;
|
||||
@@ -60,7 +61,8 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
t,
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
files,
|
||||
hasFiles,
|
||||
hasDisplayFiles,
|
||||
selectedFiles,
|
||||
dragActive,
|
||||
loading,
|
||||
@@ -169,7 +171,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && files.length === 0 && (
|
||||
{loading && !hasFiles && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@@ -200,7 +202,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length === 0 && !loading && (
|
||||
{!hasDisplayFiles && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={48} className="mb-3 opacity-50" />
|
||||
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ArrowUp, Bookmark, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload } from "lucide-react";
|
||||
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Host, SftpFilenameEncoding } from "../../types";
|
||||
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
|
||||
@@ -51,6 +51,8 @@ interface SftpModalHeaderProps {
|
||||
onCreateFile: () => void;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
showHiddenFiles: boolean;
|
||||
onToggleShowHiddenFiles: () => void;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
onNavigateToBookmark?: (path: string) => void;
|
||||
}
|
||||
@@ -91,6 +93,8 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
onCreateFile,
|
||||
onFileSelect,
|
||||
onFolderSelect,
|
||||
showHiddenFiles,
|
||||
onToggleShowHiddenFiles,
|
||||
onUpdateHost,
|
||||
onNavigateToBookmark,
|
||||
}) => {
|
||||
@@ -302,6 +306,22 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Tooltip
|
||||
open={openTooltip === 'showHiddenFiles'}
|
||||
onOpenChange={handleTooltipOpenChange('showHiddenFiles')}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={showHiddenFiles ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", showHiddenFiles && "text-primary")}
|
||||
onClick={onToggleShowHiddenFiles}
|
||||
>
|
||||
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
|
||||
{isEditingPath ? (
|
||||
|
||||
@@ -53,6 +53,7 @@ interface UseSftpModalSessionParams {
|
||||
interface UseSftpModalSessionResult {
|
||||
currentPath: string;
|
||||
setCurrentPath: (path: string) => void;
|
||||
currentPathRef: React.MutableRefObject<string>;
|
||||
files: RemoteFile[];
|
||||
setFiles: (files: RemoteFile[]) => void;
|
||||
loading: boolean;
|
||||
@@ -428,6 +429,7 @@ export const useSftpModalSession = ({
|
||||
return {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
currentPathRef,
|
||||
files,
|
||||
setFiles,
|
||||
loading,
|
||||
|
||||
@@ -35,6 +35,7 @@ type UploadTask = TransferTask;
|
||||
|
||||
interface UseSftpModalTransfersParams {
|
||||
currentPath: string;
|
||||
currentPathRef: React.MutableRefObject<string>;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
@@ -99,6 +100,7 @@ interface UseSftpModalTransfersResult {
|
||||
|
||||
export const useSftpModalTransfers = ({
|
||||
currentPath,
|
||||
currentPathRef,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
@@ -214,8 +216,16 @@ export const useSftpModalTransfers = ({
|
||||
};
|
||||
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload, startStreamTransfer, cancelTransfer]);
|
||||
|
||||
const refreshTargetPathIfCurrent = useCallback(
|
||||
async (targetPath: string) => {
|
||||
if (currentPathRef.current !== targetPath) return;
|
||||
await loadFiles(targetPath, { force: true });
|
||||
},
|
||||
[currentPathRef, loadFiles],
|
||||
);
|
||||
|
||||
// Create upload callbacks
|
||||
const createUploadCallbacks = useCallback((): UploadCallbacks => {
|
||||
const createUploadCallbacks = useCallback((targetPath: string): UploadCallbacks => {
|
||||
return {
|
||||
onScanningStart: (taskId: string) => {
|
||||
const scanningTask: UploadTask = {
|
||||
@@ -247,7 +257,7 @@ export const useSftpModalTransfers = ({
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
direction: "upload",
|
||||
targetPath: currentPath,
|
||||
targetPath,
|
||||
};
|
||||
setUploadTasks(prev => [...prev, uploadTask]);
|
||||
},
|
||||
@@ -345,16 +355,18 @@ export const useSftpModalTransfers = ({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [t, currentPath]);
|
||||
}, [t]);
|
||||
|
||||
// Helper function to perform upload with compression setting from user preference
|
||||
const performUpload = useCallback(async (
|
||||
files: FileList | File[],
|
||||
useCompressed: boolean
|
||||
useCompressed: boolean,
|
||||
targetPathOverride?: string,
|
||||
): Promise<void> => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
const targetPath = targetPathOverride ?? currentPathRef.current;
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
@@ -367,13 +379,13 @@ export const useSftpModalTransfers = ({
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
const callbacks = createUploadCallbacks(targetPath);
|
||||
|
||||
try {
|
||||
await uploadFromFileList(
|
||||
files,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
@@ -384,7 +396,7 @@ export const useSftpModalTransfers = ({
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
await refreshTargetPathIfCurrent(targetPath);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
@@ -396,7 +408,7 @@ export const useSftpModalTransfers = ({
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
}, [currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t]);
|
||||
}, [createUploadBridge, createUploadCallbacks, currentPathRef, ensureSftp, isLocalSession, joinPath, refreshTargetPathIfCurrent, t]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
@@ -820,6 +832,7 @@ export const useSftpModalTransfers = ({
|
||||
const handleUploadFromDrop = useCallback(
|
||||
async (dataTransfer: DataTransfer) => {
|
||||
setUploading(true);
|
||||
const targetPath = currentPathRef.current;
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
@@ -832,13 +845,13 @@ export const useSftpModalTransfers = ({
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
const callbacks = createUploadCallbacks(targetPath);
|
||||
|
||||
try {
|
||||
await uploadFromDataTransfer(
|
||||
dataTransfer,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
@@ -849,7 +862,7 @@ export const useSftpModalTransfers = ({
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
await refreshTargetPathIfCurrent(targetPath);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
@@ -862,7 +875,7 @@ export const useSftpModalTransfers = ({
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
|
||||
[createUploadBridge, createUploadCallbacks, currentPathRef, ensureSftp, isLocalSession, joinPath, refreshTargetPathIfCurrent, t, useCompressedUpload],
|
||||
);
|
||||
|
||||
// Handle upload from DropEntry array (used for drag-and-drop to terminal)
|
||||
@@ -871,6 +884,7 @@ export const useSftpModalTransfers = ({
|
||||
if (entries.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
const targetPath = currentPathRef.current;
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
@@ -883,13 +897,13 @@ export const useSftpModalTransfers = ({
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
const callbacks = createUploadCallbacks(targetPath);
|
||||
|
||||
try {
|
||||
await uploadEntriesDirect(
|
||||
entries,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
@@ -900,7 +914,7 @@ export const useSftpModalTransfers = ({
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
await refreshTargetPathIfCurrent(targetPath);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
@@ -913,7 +927,7 @@ export const useSftpModalTransfers = ({
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
|
||||
[createUploadBridge, createUploadCallbacks, currentPathRef, ensureSftp, isLocalSession, joinPath, refreshTargetPathIfCurrent, t, useCompressedUpload],
|
||||
);
|
||||
|
||||
// Handle upload from File array (used by file input after copying files)
|
||||
|
||||
@@ -98,9 +98,6 @@ export interface SftpContextValue {
|
||||
// Callbacks for each side
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
|
||||
// Settings
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
const SftpContext = createContext<SftpContextValue | null>(null);
|
||||
@@ -140,12 +137,6 @@ export const useSftpUpdateHosts = () => {
|
||||
return context.updateHosts;
|
||||
};
|
||||
|
||||
// Hook to get showHiddenFiles setting
|
||||
export const useSftpShowHiddenFiles = (): boolean => {
|
||||
const context = useSftpContext();
|
||||
return context.showHiddenFiles;
|
||||
};
|
||||
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
@@ -153,7 +144,6 @@ interface SftpContextProviderProps {
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
showHiddenFiles: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -164,7 +154,6 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
children,
|
||||
}) => {
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
@@ -177,9 +166,8 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
}),
|
||||
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
|
||||
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
|
||||
);
|
||||
|
||||
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
|
||||
|
||||
@@ -411,7 +411,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating directories */}
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Bookmark, Check, ChevronLeft, FilePlus, Folder, FolderPlus, Home, Languages, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { Bookmark, Check, ChevronLeft, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
@@ -47,6 +47,8 @@ interface SftpPaneToolbarProps {
|
||||
onToggleBookmark: () => void;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
showHiddenFiles: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
}
|
||||
|
||||
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
@@ -86,6 +88,8 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
onToggleBookmark,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
showHiddenFiles,
|
||||
onToggleShowHiddenFiles,
|
||||
}) => (
|
||||
<>
|
||||
{/* Toolbar - always visible when connected */}
|
||||
@@ -300,6 +304,15 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={showHiddenFiles ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", showHiddenFiles && "text-primary")}
|
||||
onClick={onToggleShowHiddenFiles}
|
||||
title={t("settings.sftp.showHiddenFiles")}
|
||||
>
|
||||
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpPaneCallbacks,
|
||||
useSftpShowHiddenFiles,
|
||||
useSftpUpdateHosts,
|
||||
} from "./index";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
@@ -58,6 +57,7 @@ interface SftpPaneViewProps {
|
||||
pane: SftpPane;
|
||||
showHeader?: boolean;
|
||||
showEmptyHeader?: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
}
|
||||
|
||||
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
@@ -65,13 +65,13 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
pane,
|
||||
showHeader = true,
|
||||
showEmptyHeader = true,
|
||||
onToggleShowHiddenFiles,
|
||||
}) => {
|
||||
const isActive = true;
|
||||
|
||||
const callbacks = useSftpPaneCallbacks(side);
|
||||
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
|
||||
const hosts = useSftpHosts();
|
||||
const showHiddenFiles = useSftpShowHiddenFiles();
|
||||
|
||||
const { t } = useI18n();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -118,7 +118,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
files: pane.files,
|
||||
filter: pane.filter,
|
||||
connection: pane.connection,
|
||||
showHiddenFiles,
|
||||
showHiddenFiles: pane.showHiddenFiles,
|
||||
sortField,
|
||||
sortOrder,
|
||||
});
|
||||
@@ -333,6 +333,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onToggleBookmark={toggleBookmark}
|
||||
onNavigateToBookmark={callbacks.onNavigateTo}
|
||||
onDeleteBookmark={deleteBookmark}
|
||||
showHiddenFiles={pane.showHiddenFiles}
|
||||
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
|
||||
/>
|
||||
|
||||
<SftpPaneFileList
|
||||
|
||||
@@ -32,7 +32,6 @@ interface UseSftpKeyboardShortcutsParams {
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
isActive: boolean;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +57,6 @@ export const useSftpKeyboardShortcuts = ({
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
isActive,
|
||||
showHiddenFiles,
|
||||
}: UseSftpKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: KeyboardEvent) => {
|
||||
@@ -238,7 +236,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
case "sftpSelectAll": {
|
||||
// Select all files in the current pane
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles);
|
||||
let visibleFiles = filterHiddenFiles(pane.files, pane.showHiddenFiles);
|
||||
if (term) {
|
||||
visibleFiles = visibleFiles.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
@@ -280,7 +278,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[hotkeyScheme, isActive, keyBindings, sftpRef, showHiddenFiles]
|
||||
[hotkeyScheme, isActive, keyBindings, sftpRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,7 +19,6 @@ export {
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpUpdateHosts,
|
||||
useSftpShowHiddenFiles,
|
||||
useActiveTabId,
|
||||
useIsPaneActive,
|
||||
activeTabStore,
|
||||
|
||||
@@ -43,6 +43,11 @@ export const useTerminalContextActions = ({
|
||||
terminalBackend.writeToSession(sessionRef.current, data);
|
||||
if (scrollOnPasteRef?.current) {
|
||||
term.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FitAddon } from "@xterm/addon-fit";
|
||||
import type { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import { shouldScrollOnTerminalOutput } from "../../../domain/terminalScroll";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
|
||||
import {
|
||||
@@ -68,8 +69,11 @@ export type TerminalSessionStartersContext = {
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
terminalSettings?: TerminalSettings;
|
||||
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
|
||||
terminalBackend: TerminalBackendApi;
|
||||
serialConfig?: SerialConfig;
|
||||
isVisibleRef?: RefObject<boolean>;
|
||||
pendingOutputScrollRef?: RefObject<boolean>;
|
||||
|
||||
sessionRef: RefObject<string | null>;
|
||||
hasConnectedRef: RefObject<boolean>;
|
||||
@@ -117,6 +121,41 @@ const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => {
|
||||
return env;
|
||||
};
|
||||
|
||||
const handleTerminalOutputAutoScroll = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
) => {
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
if (!shouldScrollOnTerminalOutput(settings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.isVisibleRef?.current === false) {
|
||||
if (ctx.pendingOutputScrollRef) {
|
||||
ctx.pendingOutputScrollRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
term.scrollToBottom();
|
||||
};
|
||||
|
||||
const writeSessionData = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
data: string,
|
||||
) => {
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
if (!shouldScrollOnTerminalOutput(settings)) {
|
||||
term.write(data);
|
||||
return;
|
||||
}
|
||||
|
||||
term.write(data, () => {
|
||||
handleTerminalOutputAutoScroll(ctx, term);
|
||||
});
|
||||
};
|
||||
|
||||
const attachSessionToTerminal = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -139,7 +178,7 @@ const attachSessionToTerminal = (
|
||||
// Replace \n that is not preceded by \r with \r\n
|
||||
data = data.replace(/(?<!\r)\n/g, "\r\n");
|
||||
}
|
||||
term.write(data);
|
||||
writeSessionData(ctx, term, data);
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
ctx.updateStatus("connected");
|
||||
opts?.onConnected?.();
|
||||
@@ -665,7 +704,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
|
||||
ctx.sessionRef.current = id;
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
term.write(chunk);
|
||||
writeSessionData(ctx, term, chunk);
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
ctx.updateStatus("connected");
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
type XTermPlatform,
|
||||
resolveXTermPerformanceConfig,
|
||||
} from "../../../infrastructure/config/xtermPerformance";
|
||||
import {
|
||||
shouldEnableNativeUserInputAutoScroll,
|
||||
shouldScrollOnTerminalInput,
|
||||
shouldScrollOnTerminalPaste,
|
||||
} from "../../../domain/terminalScroll";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
@@ -148,7 +153,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const fontWeightBold = settings?.fontWeightBold ?? 700;
|
||||
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
|
||||
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
|
||||
const scrollOnUserInput = settings?.scrollOnInput ?? true;
|
||||
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
|
||||
const altIsMeta = settings?.altAsMeta ?? false;
|
||||
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
|
||||
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
|
||||
@@ -202,6 +207,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
drawBoldTextInBrightColors,
|
||||
minimumContrastRatio,
|
||||
scrollOnUserInput,
|
||||
macOptionClickForcesSelection: true,
|
||||
altClickMovesCursor: !altIsMeta,
|
||||
wordSeparator,
|
||||
theme: {
|
||||
@@ -335,6 +341,24 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const appLevelActions = getAppLevelActions();
|
||||
const terminalActions = getTerminalPassthroughActions();
|
||||
const scrollViewportToBottom = () => {
|
||||
term.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
};
|
||||
const scrollToBottomAfterPaste = () => {
|
||||
if (shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current)) {
|
||||
scrollViewportToBottom();
|
||||
}
|
||||
};
|
||||
const scrollToBottomAfterInput = (data: string) => {
|
||||
if (shouldScrollOnTerminalInput(ctx.terminalSettingsRef.current, data)) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||
if (e.type !== "keydown") {
|
||||
@@ -421,6 +445,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
});
|
||||
break;
|
||||
@@ -456,6 +481,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[Terminal] Failed to paste from clipboard:", err);
|
||||
@@ -536,6 +562,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
|
||||
}
|
||||
|
||||
scrollToBottomAfterInput(data);
|
||||
|
||||
if (ctx.statusRef.current === "connected" && ctx.onCommandExecuted) {
|
||||
if (data === "\r" || data === "\n") {
|
||||
const cmd = ctx.commandBufferRef.current.trim();
|
||||
|
||||
@@ -13,6 +13,7 @@ export const normalizeDistroId = (value?: string) => {
|
||||
if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon';
|
||||
if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse';
|
||||
if (v.includes('red hat') || v.includes('redhat') || v.includes('rhel')) return 'redhat';
|
||||
if (v.includes('almalinux')) return 'almalinux';
|
||||
if (v.includes('oracle')) return 'oracle';
|
||||
if (v.includes('kali')) return 'kali';
|
||||
return '';
|
||||
|
||||
@@ -111,6 +111,17 @@ export interface ProviderConnection {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const hasProviderConnectionData = (
|
||||
connection: Pick<ProviderConnection, 'tokens' | 'config'>,
|
||||
): boolean => Boolean(connection.tokens || connection.config);
|
||||
|
||||
export const isProviderReadyForSync = (
|
||||
connection: Pick<ProviderConnection, 'status' | 'tokens' | 'config'>,
|
||||
): boolean =>
|
||||
connection.status === 'connected'
|
||||
|| connection.status === 'syncing'
|
||||
|| (connection.status === 'error' && hasProviderConnectionData(connection));
|
||||
|
||||
// ============================================================================
|
||||
// Encrypted Sync File Schema
|
||||
// ============================================================================
|
||||
@@ -150,7 +161,8 @@ export interface SyncPayload {
|
||||
identities?: import('./models').Identity[];
|
||||
snippets: import('./models').Snippet[];
|
||||
customGroups: string[];
|
||||
|
||||
snippetPackages?: string[];
|
||||
|
||||
// Port forwarding rules
|
||||
portForwardingRules?: import('./models').PortForwardingRule[];
|
||||
|
||||
|
||||
@@ -28,12 +28,13 @@ export interface SyncableVaultData {
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
knownHosts: KnownHost[];
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
export interface SyncPayloadImporters {
|
||||
/** Import vault data (hosts, keys, identities, snippets, customGroups, knownHosts). */
|
||||
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
|
||||
importVaultData: (jsonString: string) => void;
|
||||
/** Import port-forwarding rules (lives outside the vault hook). */
|
||||
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
|
||||
@@ -60,6 +61,7 @@ export function buildSyncPayload(
|
||||
identities: vault.identities,
|
||||
snippets: vault.snippets,
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
knownHosts: vault.knownHosts,
|
||||
portForwardingRules,
|
||||
syncedAt: Date.now(),
|
||||
@@ -87,6 +89,9 @@ export function applySyncPayload(
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
};
|
||||
if (payload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = payload.snippetPackages;
|
||||
}
|
||||
if (payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
}
|
||||
|
||||
44
domain/terminalScroll.ts
Normal file
44
domain/terminalScroll.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { TerminalSettings } from "./models";
|
||||
|
||||
const hasPrintableTerminalInput = (data: string): boolean => {
|
||||
if (data.includes("\x1b")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const char of data) {
|
||||
const codePoint = char.codePointAt(0);
|
||||
if (codePoint === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (codePoint >= 0x20 && codePoint !== 0x7f && codePoint !== 0x1b) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const shouldEnableNativeUserInputAutoScroll = (
|
||||
settings?: Partial<TerminalSettings> | null,
|
||||
): boolean => settings?.scrollOnInput ?? true;
|
||||
|
||||
export const shouldScrollOnTerminalInput = (
|
||||
settings: Partial<TerminalSettings> | null | undefined,
|
||||
data: string,
|
||||
): boolean => {
|
||||
const scrollOnInput = settings?.scrollOnInput ?? true;
|
||||
const scrollOnKeyPress = settings?.scrollOnKeyPress ?? false;
|
||||
|
||||
if (!scrollOnInput && !scrollOnKeyPress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasPrintableTerminalInput(data) ? scrollOnInput : scrollOnKeyPress;
|
||||
};
|
||||
|
||||
export const shouldScrollOnTerminalOutput = (
|
||||
settings?: Partial<TerminalSettings> | null,
|
||||
): boolean => settings?.scrollOnOutput ?? false;
|
||||
|
||||
export const shouldScrollOnTerminalPaste = (
|
||||
settings?: Partial<TerminalSettings> | null,
|
||||
): boolean => settings?.scrollOnPaste ?? true;
|
||||
@@ -31,11 +31,27 @@ function isAutoUpdateSupported() {
|
||||
/** Lazily resolved autoUpdater — avoids importing electron-updater in
|
||||
* contexts where native modules might not be available. */
|
||||
let _autoUpdater = null;
|
||||
|
||||
/** Guard against duplicate listener registration */
|
||||
let _listenersRegistered = false;
|
||||
|
||||
/** Track whether a download is in progress to distinguish download errors from check errors */
|
||||
let _isDownloading = false;
|
||||
|
||||
/** Track whether a checkForUpdates call is in flight (set before call, cleared on result event) */
|
||||
let _isChecking = false;
|
||||
|
||||
/**
|
||||
* Snapshot of the last known update status so newly opened windows can hydrate
|
||||
* without waiting for the next IPC event.
|
||||
* @type {{ status: 'idle' | 'downloading' | 'ready' | 'error', percent: number, error: string | null, version: string | null, isChecking: boolean }}
|
||||
*/
|
||||
let _lastStatus = { status: 'idle', percent: 0, error: null, version: null, isChecking: false };
|
||||
function getAutoUpdater() {
|
||||
if (_autoUpdater) return _autoUpdater;
|
||||
try {
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
// Silence the default electron-log transport (we log ourselves).
|
||||
autoUpdater.logger = null;
|
||||
@@ -47,28 +63,156 @@ function getAutoUpdater() {
|
||||
}
|
||||
}
|
||||
|
||||
function init(deps) {
|
||||
_deps = deps;
|
||||
/**
|
||||
* Register persistent global IPC event listeners for auto-download flow.
|
||||
* Called once in init(). Forwards electron-updater events to the renderer
|
||||
* even when no manual download was initiated.
|
||||
*/
|
||||
function setupGlobalListeners() {
|
||||
if (_listenersRegistered) return;
|
||||
const updater = getAutoUpdater();
|
||||
if (!updater) return;
|
||||
_listenersRegistered = true;
|
||||
|
||||
updater.on("update-not-available", () => {
|
||||
_isChecking = false;
|
||||
// Reset stale status so late-opening windows don't hydrate from a
|
||||
// previous 'error' or 'ready' snapshot after a "no update" check.
|
||||
_lastStatus = { status: 'idle', percent: 0, error: null, version: null, isChecking: false };
|
||||
broadcastToAllWindows("netcatty:update:update-not-available", {});
|
||||
});
|
||||
|
||||
updater.on("update-available", (info) => {
|
||||
_isChecking = false;
|
||||
// autoDownload=true means the download begins immediately after this event
|
||||
_isDownloading = true;
|
||||
_lastStatus = { status: 'downloading', percent: 0, error: null, version: info.version || null, isChecking: false };
|
||||
broadcastToAllWindows("netcatty:update:update-available", {
|
||||
version: info.version || "",
|
||||
releaseNotes: typeof info.releaseNotes === "string" ? info.releaseNotes : "",
|
||||
releaseDate: info.releaseDate || null,
|
||||
});
|
||||
});
|
||||
|
||||
updater.on("download-progress", (info) => {
|
||||
_lastStatus.percent = Math.round(info.percent ?? 0);
|
||||
broadcastToAllWindows("netcatty:update:download-progress", {
|
||||
percent: info.percent ?? 0,
|
||||
bytesPerSecond: info.bytesPerSecond ?? 0,
|
||||
transferred: info.transferred ?? 0,
|
||||
total: info.total ?? 0,
|
||||
});
|
||||
});
|
||||
|
||||
updater.on("update-downloaded", () => {
|
||||
_isDownloading = false;
|
||||
_lastStatus = { ..._lastStatus, status: 'ready', percent: 100 };
|
||||
broadcastToAllWindows("netcatty:update:downloaded");
|
||||
});
|
||||
|
||||
updater.on("error", (err) => {
|
||||
_isChecking = false;
|
||||
// Only broadcast download-phase errors; check-phase errors (e.g. network failures
|
||||
// during checkForUpdates) are not download failures and must not set autoDownloadStatus.
|
||||
if (!_isDownloading) {
|
||||
_lastStatus = { ..._lastStatus, isChecking: false };
|
||||
console.warn("[AutoUpdate] Check-phase error (not broadcast to renderer):", err?.message || err);
|
||||
return;
|
||||
}
|
||||
_isDownloading = false;
|
||||
const errorMsg = err?.message || "Unknown update error";
|
||||
_lastStatus = { ..._lastStatus, status: 'error', error: errorMsg };
|
||||
broadcastToAllWindows("netcatty:update:error", {
|
||||
error: errorMsg,
|
||||
});
|
||||
});
|
||||
|
||||
console.log("[AutoUpdate] Global listeners registered");
|
||||
}
|
||||
|
||||
/** Get the focused or first available BrowserWindow to send events to. */
|
||||
function getSenderWindow() {
|
||||
/**
|
||||
* Trigger an automatic update check after a delay.
|
||||
* No-op on platforms that don't support auto-update (Linux deb/rpm/snap).
|
||||
* Called from main process after the main window is created.
|
||||
*
|
||||
* @param {number} delayMs - Milliseconds to wait before checking (default: 5000)
|
||||
*/
|
||||
let _autoCheckTimer = null;
|
||||
|
||||
function startAutoCheck(delayMs = 5000) {
|
||||
if (!isAutoUpdateSupported()) {
|
||||
console.log("[AutoUpdate] Platform does not support auto-update, skipping auto-check");
|
||||
return;
|
||||
}
|
||||
_autoCheckTimer = setTimeout(async () => {
|
||||
_autoCheckTimer = null;
|
||||
const updater = getAutoUpdater();
|
||||
if (!updater) {
|
||||
console.warn("[AutoUpdate] Auto-check skipped — updater not available");
|
||||
return;
|
||||
}
|
||||
_isChecking = true;
|
||||
_lastStatus = { ..._lastStatus, isChecking: true };
|
||||
try {
|
||||
console.log("[AutoUpdate] Starting automatic update check...");
|
||||
await updater.checkForUpdates();
|
||||
} catch (err) {
|
||||
_isChecking = false;
|
||||
_lastStatus = { ..._lastStatus, isChecking: false };
|
||||
console.warn("[AutoUpdate] Auto-check failed:", err?.message || err);
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending startAutoCheck timer. Called when the renderer triggers
|
||||
* a manual check to avoid racing with the queued auto-check.
|
||||
*/
|
||||
function cancelAutoCheck() {
|
||||
if (_autoCheckTimer) {
|
||||
clearTimeout(_autoCheckTimer);
|
||||
_autoCheckTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function init(deps) {
|
||||
_deps = deps;
|
||||
setupGlobalListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an IPC event to all non-destroyed BrowserWindows.
|
||||
* Ensures both the main window and settings window always receive
|
||||
* auto-update events.
|
||||
* @param {string} channel
|
||||
* @param {unknown} [payload]
|
||||
*/
|
||||
function broadcastToAllWindows(channel, payload) {
|
||||
try {
|
||||
const { BrowserWindow } = _deps?.electronModule || {};
|
||||
if (!BrowserWindow) return null;
|
||||
const focused = BrowserWindow.getFocusedWindow();
|
||||
if (focused && !focused.isDestroyed()) return focused;
|
||||
const all = BrowserWindow.getAllWindows();
|
||||
for (const win of all) {
|
||||
if (!win.isDestroyed()) return win;
|
||||
if (!BrowserWindow) return;
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed()) {
|
||||
if (payload !== undefined) {
|
||||
win.webContents.send(channel, payload);
|
||||
} else {
|
||||
win.webContents.send(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn("[AutoUpdate] broadcastToAllWindows failed:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
function registerHandlers(ipcMain) {
|
||||
// ---- Check for updates ------------------------------------------------
|
||||
ipcMain.handle("netcatty:update:check", async () => {
|
||||
// Cancel any pending auto-check to prevent concurrent checkForUpdates()
|
||||
// calls — electron-updater rejects them and surfaces false errors.
|
||||
cancelAutoCheck();
|
||||
|
||||
if (!isAutoUpdateSupported()) {
|
||||
return {
|
||||
available: false,
|
||||
@@ -86,7 +230,16 @@ function registerHandlers(ipcMain) {
|
||||
};
|
||||
}
|
||||
|
||||
// If a check is already in flight (e.g. from startAutoCheck), don't
|
||||
// start a concurrent one — electron-updater rejects it and surfaces a
|
||||
// confusing error. Return a sentinel so the renderer knows to wait.
|
||||
if (_isChecking) {
|
||||
return { available: false, supported: true, checking: true };
|
||||
}
|
||||
|
||||
try {
|
||||
_isChecking = true;
|
||||
_lastStatus = { ..._lastStatus, isChecking: true };
|
||||
const result = await updater.checkForUpdates();
|
||||
if (!result || !result.updateInfo) {
|
||||
return { available: false, supported: true };
|
||||
@@ -112,6 +265,8 @@ function registerHandlers(ipcMain) {
|
||||
releaseDate: releaseDate || null,
|
||||
};
|
||||
} catch (err) {
|
||||
_isChecking = false;
|
||||
_lastStatus = { ..._lastStatus, isChecking: false };
|
||||
console.warn("[AutoUpdate] Check failed:", err?.message || err);
|
||||
return {
|
||||
available: false,
|
||||
@@ -127,65 +282,22 @@ function registerHandlers(ipcMain) {
|
||||
if (!updater) {
|
||||
return { success: false, error: "Update module not available." };
|
||||
}
|
||||
|
||||
try {
|
||||
// Capture the requesting window NOW so events always go back to the
|
||||
// renderer that initiated the download, even if focus changes later.
|
||||
const senderWindow = getSenderWindow();
|
||||
|
||||
// Wire progress events before starting the download.
|
||||
const progressHandler = (info) => {
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
senderWindow.webContents.send("netcatty:update:download-progress", {
|
||||
percent: info.percent ?? 0,
|
||||
bytesPerSecond: info.bytesPerSecond ?? 0,
|
||||
transferred: info.transferred ?? 0,
|
||||
total: info.total ?? 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadedHandler = () => {
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
senderWindow.webContents.send("netcatty:update:downloaded");
|
||||
}
|
||||
// Cleanup one-shot listeners.
|
||||
updater.removeListener("download-progress", progressHandler);
|
||||
updater.removeListener("update-downloaded", downloadedHandler);
|
||||
updater.removeListener("error", errorHandler);
|
||||
};
|
||||
|
||||
const errorHandler = (err) => {
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
senderWindow.webContents.send("netcatty:update:error", {
|
||||
error: err?.message || "Download failed",
|
||||
});
|
||||
}
|
||||
updater.removeListener("download-progress", progressHandler);
|
||||
updater.removeListener("update-downloaded", downloadedHandler);
|
||||
updater.removeListener("error", errorHandler);
|
||||
};
|
||||
|
||||
updater.on("download-progress", progressHandler);
|
||||
updater.on("update-downloaded", downloadedHandler);
|
||||
updater.on("error", errorHandler);
|
||||
|
||||
// Global listeners (registered in setupGlobalListeners) handle all
|
||||
// progress/downloaded/error events. Just trigger the download.
|
||||
await updater.downloadUpdate();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Clean up listeners to prevent leaks if downloadUpdate() rejects
|
||||
// before the error event is emitted.
|
||||
const updaterForCleanup = getAutoUpdater();
|
||||
if (updaterForCleanup) {
|
||||
updaterForCleanup.removeAllListeners("download-progress");
|
||||
updaterForCleanup.removeAllListeners("update-downloaded");
|
||||
updaterForCleanup.removeAllListeners("error");
|
||||
}
|
||||
console.error("[AutoUpdate] Download failed:", err?.message || err);
|
||||
return { success: false, error: err?.message || "Download failed" };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Get current update status (for late-opening windows) ---------------
|
||||
ipcMain.handle("netcatty:update:getStatus", () => {
|
||||
return { ..._lastStatus };
|
||||
});
|
||||
|
||||
// ---- Install (quit & install) ------------------------------------------
|
||||
ipcMain.handle("netcatty:update:install", () => {
|
||||
const updater = getAutoUpdater();
|
||||
@@ -196,4 +308,4 @@ function registerHandlers(ipcMain) {
|
||||
console.log("[AutoUpdate] Handlers registered");
|
||||
}
|
||||
|
||||
module.exports = { init, registerHandlers, isAutoUpdateSupported };
|
||||
module.exports = { init, registerHandlers, isAutoUpdateSupported, startAutoCheck };
|
||||
|
||||
@@ -1094,7 +1094,10 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
ipcMain.handle("netcatty:setTheme", (_event, theme) => {
|
||||
currentTheme = theme;
|
||||
nativeTheme.themeSource = theme;
|
||||
const themeConfig = THEME_COLORS[theme] || THEME_COLORS.light;
|
||||
const effectiveTheme = theme === "system"
|
||||
? (nativeTheme?.shouldUseDarkColors ? "dark" : "light")
|
||||
: theme;
|
||||
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.setBackgroundColor(themeConfig.background);
|
||||
}
|
||||
|
||||
@@ -794,7 +794,11 @@ if (!gotLock) {
|
||||
});
|
||||
|
||||
// Create the main window
|
||||
void createWindow().catch((err) => {
|
||||
void createWindow().then(() => {
|
||||
// Trigger auto-update check 5 s after window creation.
|
||||
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
|
||||
autoUpdateBridge.startAutoCheck(5000);
|
||||
}).catch((err) => {
|
||||
console.error("[Main] Failed to create main window:", err);
|
||||
showStartupError(err);
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,8 @@ const passphraseListeners = new Set();
|
||||
const passphraseTimeoutListeners = new Set();
|
||||
const updateDownloadProgressListeners = new Set();
|
||||
const updateDownloadedListeners = new Set();
|
||||
const updateAvailableListeners = new Set();
|
||||
const updateNotAvailableListeners = new Set();
|
||||
const updateErrorListeners = new Set();
|
||||
|
||||
function cleanupTransferListeners(transferId) {
|
||||
@@ -135,6 +137,26 @@ ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
|
||||
});
|
||||
|
||||
// Auto-update events
|
||||
ipcRenderer.on("netcatty:update:update-available", (_event, payload) => {
|
||||
updateAvailableListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("onUpdateAvailable callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:update:update-not-available", () => {
|
||||
updateNotAvailableListeners.forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("onUpdateNotAvailable callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:update:download-progress", (_event, payload) => {
|
||||
updateDownloadProgressListeners.forEach((cb) => {
|
||||
try {
|
||||
@@ -923,6 +945,15 @@ const api = {
|
||||
checkForUpdate: () => ipcRenderer.invoke("netcatty:update:check"),
|
||||
downloadUpdate: () => ipcRenderer.invoke("netcatty:update:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("netcatty:update:install"),
|
||||
getUpdateStatus: () => ipcRenderer.invoke("netcatty:update:getStatus"),
|
||||
onUpdateAvailable: (cb) => {
|
||||
updateAvailableListeners.add(cb);
|
||||
return () => updateAvailableListeners.delete(cb);
|
||||
},
|
||||
onUpdateNotAvailable: (cb) => {
|
||||
updateNotAvailableListeners.add(cb);
|
||||
return () => updateNotAvailableListeners.delete(cb);
|
||||
},
|
||||
onUpdateDownloadProgress: (cb) => {
|
||||
updateDownloadProgressListeners.add(cb);
|
||||
return () => updateDownloadProgressListeners.delete(cb);
|
||||
|
||||
@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
10
global.d.ts
vendored
10
global.d.ts
vendored
@@ -375,7 +375,7 @@ declare global {
|
||||
getHomeDir?(): Promise<string>;
|
||||
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
|
||||
|
||||
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
|
||||
setTheme?(theme: 'light' | 'dark' | 'system'): Promise<boolean>;
|
||||
setBackgroundColor?(color: string): Promise<boolean>;
|
||||
setLanguage?(language: string): Promise<boolean>;
|
||||
// Window controls for custom title bar (Windows/Linux)
|
||||
@@ -622,12 +622,20 @@ declare global {
|
||||
}>;
|
||||
downloadUpdate?(): Promise<{ success: boolean; error?: string }>;
|
||||
installUpdate?(): void;
|
||||
getUpdateStatus?(): Promise<{ status: 'idle' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
|
||||
|
||||
onUpdateDownloadProgress?(cb: (progress: {
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}) => void): () => void;
|
||||
onUpdateAvailable?(cb: (info: {
|
||||
version: string;
|
||||
releaseNotes: string;
|
||||
releaseDate: string | null;
|
||||
}) => void): () => void;
|
||||
onUpdateNotAvailable?(cb: () => void): () => void;
|
||||
onUpdateDownloaded?(cb: () => void): () => void;
|
||||
onUpdateError?(cb: (payload: { error: string }) => void): () => void;
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hos
|
||||
// Update check
|
||||
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
|
||||
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
|
||||
export const STORAGE_KEY_UPDATE_LATEST_RELEASE = 'netcatty_update_latest_release_v1';
|
||||
|
||||
// SFTP File Opener Associations
|
||||
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SYNC_STORAGE_KEYS,
|
||||
generateDeviceId,
|
||||
getDefaultDeviceName,
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import packageJson from '../../package.json';
|
||||
import { EncryptionService } from './EncryptionService';
|
||||
@@ -945,6 +946,7 @@ export class CloudSyncManager {
|
||||
): Promise<SyncResult> {
|
||||
try {
|
||||
await adapter.upload(syncedFile);
|
||||
this.state.lastError = null;
|
||||
|
||||
// Update local state (safe to do multiple times if values are same)
|
||||
this.state.localVersion = syncedFile.meta.version;
|
||||
@@ -984,6 +986,7 @@ export class CloudSyncManager {
|
||||
this.emit({ type: 'SYNC_COMPLETED', provider, result });
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.state.lastError = String(error);
|
||||
this.updateProviderStatus(provider, 'error', String(error));
|
||||
|
||||
// Add to sync history
|
||||
@@ -1016,6 +1019,7 @@ export class CloudSyncManager {
|
||||
keys: SyncPayload['keys'];
|
||||
snippets: SyncPayload['snippets'];
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
settings?: SyncPayload['settings'];
|
||||
@@ -1064,6 +1068,7 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
this.updateProviderStatus(provider, 'syncing');
|
||||
this.state.lastError = null;
|
||||
this.state.syncState = 'SYNCING';
|
||||
this.emit({ type: 'SYNC_STARTED', provider });
|
||||
|
||||
@@ -1252,17 +1257,15 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
const connectedProviders = Object.entries(this.state.providers)
|
||||
.filter(([p, conn]) => {
|
||||
if (conn.status === 'connected') return true;
|
||||
// Auto-recover: retry providers stuck in 'error' if tokens/config still exist
|
||||
if (conn.status === 'error' && (conn.tokens || conn.config)) {
|
||||
this.state.providers[p as CloudProvider].status = 'connected';
|
||||
this.state.providers[p as CloudProvider].error = undefined;
|
||||
.filter(([provider, connection]) => {
|
||||
if (!isProviderReadyForSync(connection)) return false;
|
||||
if (connection.status === 'error') {
|
||||
this.state.providers[provider as CloudProvider].status = 'connected';
|
||||
this.state.providers[provider as CloudProvider].error = undefined;
|
||||
// Clear cached adapter so a fresh one is created with current (decrypted) tokens
|
||||
this.adapters.delete(p as CloudProvider);
|
||||
return true;
|
||||
this.adapters.delete(provider as CloudProvider);
|
||||
}
|
||||
return false;
|
||||
return true;
|
||||
})
|
||||
.map(([p]) => p as CloudProvider);
|
||||
|
||||
@@ -1270,6 +1273,7 @@ export class CloudSyncManager {
|
||||
return results;
|
||||
}
|
||||
|
||||
this.state.lastError = null;
|
||||
this.state.syncState = 'SYNCING';
|
||||
|
||||
// 1. Parallel Checks
|
||||
|
||||
2
public/distro/almalinux.svg
Normal file
2
public/distro/almalinux.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>AlmaLinux</title><path d="M23.994 15.133c.079 1.061-.668 1.927-1.69 2.005a1.8 1.8 0 0 1-1.928-1.651c-.078-1.062.63-1.849 1.691-1.967c1.023-.078 1.849.59 1.927 1.613m-12.623 4.955c-.944 0-1.73.786-1.73 1.809c0 1.14.747 1.848 1.887 1.848c.904-.04 1.691-.865 1.691-1.809c0-.983-.904-1.848-1.848-1.848m1.061-9.675c-.039-.865-.078-1.73.08-2.556c.156-.944.314-1.887.904-2.674c.707-.983 1.809-.944 2.399.118c.314.511.432 1.062.471 1.652c0 .354.158.432.472.393c.944-.157 1.888-.157 2.792.197c.118.039.236.118.394 0c.314-.276.393-1.652.196-2.006c-.354-.63-.904-.55-1.455-.55c-.629.039-1.18-.158-1.612-.67c-.393-.471-.511-1.06-.59-1.65c-.04-.276-.079-.512-.315-.709c-.55-.55-1.809-.432-2.477.118c-2.556 2.045-2.989 5.467-1.534 8.18c.04.118.118.236.275.157m7.984 3.658c.354-.511.865-.747 1.415-.983a.97.97 0 0 0 .59-.472c.354-.669-.078-1.81-.747-2.36c-2.595-2.006-5.938-1.612-8.18.433c-.118.078-.157.196-.078.314c.786-.236 1.612-.472 2.477-.51c.905-.08 1.848-.158 2.753.235c1.14.472 1.337 1.534.472 2.36c-.393.393-.905.668-1.455.825c-.315.08-.354.236-.236.551c.354.865.59 1.77.472 2.753c-.04.157-.079.275.078.393c.354.236 1.691 0 1.967-.275c.511-.472.314-1.023.196-1.534c-.157-.63-.078-1.219.276-1.73m-7.197-2.045c-.118-.079-.197-.118-.315 0c.472.708.905 1.455 1.259 2.241c.314.866.668 1.73.55 2.714c-.118 1.18-1.1 1.69-2.123 1.101c-.511-.275-.905-.669-1.22-1.14c-.196-.276-.393-.276-.629-.08c-.747.63-1.533 1.102-2.516 1.26c-.158 0-.315 0-.394.157c-.118.393.472 1.612.826 1.809c.59.354 1.062 0 1.534-.276c.55-.314 1.101-.432 1.73-.236c.59.197.983.63 1.337 1.102c.158.196.315.353.63.432c.747.197 1.77-.59 2.084-1.376c1.18-3.028-.157-6.135-2.753-7.708m-2.556 2.438c.472-.669.826-1.416.983-2.202c-.157-.04-.197.04-.315.078c-.904.944-1.848 1.849-3.067 2.478c-.472.236-.983.433-1.534.433c-.865 0-1.376-.551-1.298-1.416a2.92 2.92 0 0 1 .787-1.849c.236-.275.236-.432-.04-.668c-.786-.55-1.494-1.22-1.848-2.124c-.078-.275-.275-.275-.51-.157a4 4 0 0 0-.434.236c-1.022.63-1.14 1.416-.275 2.28c.63.63.944 1.338.708 2.203c-.118.433-.354.747-.63 1.101a.95.95 0 0 0-.235.787c.079.747.826 1.494 1.73 1.573c2.517.236 4.562-.63 5.978-2.753m-4.68-5.152c1.376 1.18 3.067 1.455 4.837 1.377c.157 0 .315 0 .354-.118c.04-.197-.157-.197-.275-.236c-.826-.354-1.691-.63-2.438-1.14S6.848 8.25 6.534 7.266c-.236-.747.078-1.415.825-1.651c.669-.236 1.337-.236 1.967 0c.393.157.55.078.629-.354c.118-.747.354-1.455.826-2.085c.55-.786.55-.865-.354-1.376c-.04 0-.04-.04-.079-.04c-.865-.471-1.534-.196-1.848.709c-.472 1.376-1.377 1.887-2.832 1.612a4 4 0 0 0-.472-.079c-.747.118-1.18.55-1.297 1.14c-.158 1.81.786 3.107 2.084 4.17m-2.32 3.658c-.079-.944-1.023-1.652-2.045-1.534c-.905.079-1.691 1.022-1.613 1.966c.08.983 1.023 1.77 1.967 1.652c1.14-.079 1.73-1.18 1.69-2.084zm15.18-8.298c.943-.079 1.73-.983 1.651-1.927c-.078-.983-1.022-1.77-2.005-1.691c-1.023.079-1.73.983-1.652 1.966s.983 1.73 2.006 1.652m-12.27-.826c1.062-.157 1.77-1.023 1.652-2.045C8.107.897 7.163.149 6.18.267c-1.062.118-1.691.944-1.573 2.085c.118.865 1.061 1.612 1.966 1.494"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
Reference in New Issue
Block a user