Compare commits

...

9 Commits

Author SHA1 Message Date
陈大猫
cb5333e336 Merge pull request #320 from binaricat/codex/sftpmodal-parent-entry
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Fix SFTP modal parent navigation in empty directories
2026-03-12 19:00:41 +08:00
bincxz
d3153148c8 Fix SFTP modal empty directory parent navigation 2026-03-12 18:57:19 +08:00
陈大猫
899cb109b4 Merge pull request #319 from binaricat/codex/fix-sftp-drop-target-race
fix: keep terminal drag-drop uploads on the resolved SFTP path
2026-03-12 18:47:15 +08:00
bincxz
d031bf355d fix: use resolved sftp path for initial auto upload 2026-03-12 18:40:24 +08:00
bincxz
489b7711f5 fix: pin terminal drop uploads to the resolved sftp path 2026-03-12 18:07:10 +08:00
陈大猫
65877fd912 feat(sync): include snippetPackages in cloud sync payload (#318)
* feat(sync): include snippetPackages in cloud sync payload (#315)

Snippet packages (the grouping tree for code snippets) were not included
in the cloud sync payload, causing them to be lost when syncing across
devices. This adds snippetPackages as an optional field following the
same backward-compatible pattern used by knownHosts and
portForwardingRules: old payloads that lack the field leave local
packages untouched.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: make snippetPackages optional in SyncableVaultData for consistency

Aligns with the pattern used by knownHosts — optional in both
SyncableVaultData and SyncPayload so that legacy data without the field
is handled gracefully. Also updates the SyncPayloadImporters docstring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:02:52 +08:00
陈大猫
117ec260b6 fix: address issue #294 follow-up regressions (#316)
* fix: address issue 294 regressions

* fix: scope sftp hidden files toggle per pane

* fix: restore terminal auto-follow behaviors

* fix: keep keypress auto-scroll scoped to keypress

* feat: add hidden files toggle to sftp modal

* fix: tighten sftp and terminal review findings
2026-03-12 16:19:22 +08:00
陈大猫
c76ff7ac9a Merge pull request #317 from yuzifu/feat-support-almalinux
feat: support almalinux distro
2026-03-12 15:37:21 +08:00
yuzifu
17da21b1cd feat: support almalinux distro 2026-03-12 14:49:54 +08:00
38 changed files with 419 additions and 128 deletions

View File

@@ -283,6 +283,7 @@ function App({ settings }: { settings: SettingsState }) {
identities,
snippets,
customGroups,
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
onApplyPayload: (payload) => {

View File

@@ -257,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',

View File

@@ -1129,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 按住 OptionWindows/Linux 按住 Shift 拖选即可选中文本',
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',

View File

@@ -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;
}

View File

@@ -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],

View File

@@ -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,

View File

@@ -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;

View File

@@ -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),

View File

@@ -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);

View File

@@ -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),

View File

@@ -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,
],
);

View File

@@ -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')}

View File

@@ -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",
};

View File

@@ -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}

View File

@@ -34,6 +34,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
identities,
snippets,
customGroups,
snippetPackages,
knownHosts,
importDataFromString,
clearVaultData,
@@ -54,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 (

View File

@@ -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>
))}

View File

@@ -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';
};

View File

@@ -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);
}
}}

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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,

View File

@@ -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)

View File

@@ -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>;

View File

@@ -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"

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -19,7 +19,6 @@ export {
useSftpDrag,
useSftpHosts,
useSftpUpdateHosts,
useSftpShowHiddenFiles,
useActiveTabId,
useIsPaneActive,
activeTabStore,

View File

@@ -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) {

View File

@@ -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(() => {

View File

@@ -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();

View File

@@ -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 '';

View File

@@ -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[];

View File

@@ -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
View 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;

View File

@@ -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);
}

2
global.d.ts vendored
View File

@@ -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)

View File

@@ -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

View 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