Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b24e38326 | ||
|
|
b972866c8e | ||
|
|
8c541fb6e2 | ||
|
|
b73e60fb6d | ||
|
|
a40e2f1ca7 | ||
|
|
834a677cfe | ||
|
|
55ee08315a | ||
|
|
a712b96d57 | ||
|
|
f5b745ec63 | ||
|
|
3a5dd62791 |
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@@ -122,16 +122,22 @@ jobs:
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Prepare node-pty Linux runtime
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
run: bash scripts/ensure-node-pty-linux.sh prepare x64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-x64
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
||||
|
||||
- name: Verify packaged deb artifact
|
||||
run: bash scripts/verify-linux-deb-artifact.sh amd64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -198,6 +204,9 @@ jobs:
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify arm64
|
||||
|
||||
- name: Verify packaged deb artifact
|
||||
run: bash scripts/verify-linux-deb-artifact.sh arm64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -613,6 +613,7 @@ const en: Messages = {
|
||||
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
|
||||
'sftp.showHiddenPaths': 'Hidden paths',
|
||||
'sftp.task.waiting': 'Waiting...',
|
||||
'sftp.transfer.preparing': 'preparing...',
|
||||
'sftp.status.loading': 'Loading...',
|
||||
'sftp.status.uploading': 'Uploading...',
|
||||
'sftp.status.ready': 'Ready',
|
||||
|
||||
@@ -440,6 +440,7 @@ const zhCN: Messages = {
|
||||
'sftp.path.doubleClickToEdit': '双击编辑路径',
|
||||
'sftp.showHiddenPaths': '隐藏的路径',
|
||||
'sftp.task.waiting': '等待中...',
|
||||
'sftp.transfer.preparing': '准备中...',
|
||||
'sftp.status.loading': '加载中...',
|
||||
'sftp.status.uploading': '上传中...',
|
||||
'sftp.status.ready': '就绪',
|
||||
|
||||
@@ -34,7 +34,7 @@ interface UseSftpConnectionsParams {
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => Promise<void>;
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
@@ -69,7 +69,7 @@ export const useSftpConnections = ({
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
|
||||
const connect = useCallback(
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => {
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
@@ -88,6 +88,11 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!activeTabId) return;
|
||||
|
||||
// Notify caller of the tab ID synchronously, before any async work.
|
||||
// This allows callers to map metadata (e.g. connection keys) to the tab
|
||||
// immediately, avoiding race conditions with deferred effects.
|
||||
options?.onTabCreated?.(activeTabId);
|
||||
|
||||
const connectionId = `${side}-${Date.now()}`;
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
@@ -118,12 +123,15 @@ export const useSftpConnections = ({
|
||||
if (currentPane?.connection && !currentPane.connection.isLocal) {
|
||||
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
|
||||
if (oldSftpId) {
|
||||
// Delete the mapping BEFORE the async closeSftp call to prevent
|
||||
// concurrent code from using a stale sftpId that the backend may
|
||||
// have already removed during the await.
|
||||
sftpSessionsRef.current.delete(currentPane.connection.id);
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(oldSftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
sftpSessionsRef.current.delete(currentPane.connection.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export type { UploadResult };
|
||||
|
||||
interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
clearDirCacheEntry?: (connectionId: string, path: string) => void;
|
||||
@@ -524,6 +524,7 @@ export const useSftpExternalOperations = (
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
const uploadPaneId = pane.id;
|
||||
// Create a new upload controller for this upload
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
@@ -550,7 +551,7 @@ export const useSftpExternalOperations = (
|
||||
controller
|
||||
);
|
||||
|
||||
await refresh(side);
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
@@ -594,6 +595,9 @@ export const useSftpExternalOperations = (
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
// Capture the pane ID now so we can refresh the correct tab after
|
||||
// upload, even if focus switches during the transfer.
|
||||
const uploadPaneId = pane.id;
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
const uploadTargetPath = options?.targetPath || pane.connection.currentPath;
|
||||
@@ -623,17 +627,14 @@ export const useSftpExternalOperations = (
|
||||
controller,
|
||||
);
|
||||
|
||||
// Refresh the current directory and invalidate the upload target's
|
||||
// cache entry. If the user navigated away during the upload, the
|
||||
// invalidation ensures returning to the target path triggers a fresh
|
||||
// listing instead of serving stale cached data.
|
||||
const livePane = getActivePane(side);
|
||||
if (livePane?.connection) {
|
||||
if (livePane.connection.currentPath !== uploadTargetPath && clearDirCacheEntry) {
|
||||
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
|
||||
}
|
||||
await refresh(side);
|
||||
// Refresh the specific tab that initiated the upload (not whichever
|
||||
// tab is active now — focus may have switched during the transfer).
|
||||
// Also invalidate the upload target's cache entry so returning to
|
||||
// that path triggers a fresh listing.
|
||||
if (clearDirCacheEntry) {
|
||||
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
|
||||
}
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
|
||||
@@ -29,8 +29,8 @@ interface UseSftpPaneActionsParams {
|
||||
}
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<void>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
|
||||
@@ -114,23 +114,18 @@ export const useSftpPaneActions = ({
|
||||
async (
|
||||
side: "left" | "right",
|
||||
path: string,
|
||||
options?: { force?: boolean },
|
||||
options?: { force?: boolean; tabId?: string },
|
||||
) => {
|
||||
console.log("[SFTP navigateTo] called", { side, path, force: options?.force });
|
||||
|
||||
const pane = getActivePane(side);
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const activeTabId = sideTabs.activeTabId;
|
||||
// When tabId is specified, target that specific tab instead of the active one.
|
||||
// This allows refreshing a background tab (e.g. after a transfer completes
|
||||
// while focus has switched to another host).
|
||||
const targetTabId = options?.tabId ?? sideTabs.activeTabId;
|
||||
const pane = options?.tabId
|
||||
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||||
: getActivePane(side);
|
||||
|
||||
console.log("[SFTP navigateTo] state check", {
|
||||
paneId: pane?.id,
|
||||
hasConnection: !!pane?.connection,
|
||||
activeTabId,
|
||||
currentPath: pane?.connection?.currentPath,
|
||||
});
|
||||
|
||||
if (!pane?.connection || !activeTabId) {
|
||||
console.log("[SFTP navigateTo] No pane/connection/activeTabId, returning early");
|
||||
if (!pane?.connection || !targetTabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,15 +141,14 @@ export const useSftpPaneActions = ({
|
||||
Date.now() - cached.timestamp < dirCacheTtlMs &&
|
||||
cached.files
|
||||
) {
|
||||
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
tabNavSeqRef.current.set(targetTabId, requestId);
|
||||
lastConfirmedRef.current.set(targetTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files: cached.files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
@@ -180,29 +174,28 @@ export const useSftpPaneActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
|
||||
// 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);
|
||||
const existing = lastConfirmedRef.current.get(targetTabId);
|
||||
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
lastConfirmedRef.current.set(targetTabId, {
|
||||
connectionId,
|
||||
path: pane.connection.currentPath,
|
||||
files: pane.files,
|
||||
selectedFiles: pane.selectedFiles,
|
||||
});
|
||||
}
|
||||
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
|
||||
const confirmed = lastConfirmedRef.current.get(targetTabId)!;
|
||||
const previousPath = confirmed.path;
|
||||
const previousFiles = confirmed.files;
|
||||
const previousSelection = confirmed.selectedFiles;
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
tabNavSeqRef.current.set(targetTabId, 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) => ({
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
@@ -221,16 +214,17 @@ export const useSftpPaneActions = ({
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: "SFTP session lost. Please reconnect.",
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
}));
|
||||
// For background tabs (explicit tabId), update that tab directly
|
||||
// instead of handleSessionError which targets the active tab.
|
||||
if (options?.tabId) {
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
error: "sftp.error.sessionLost",
|
||||
loading: false,
|
||||
}));
|
||||
} else {
|
||||
handleSessionError(side, new Error("SFTP session lost"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,16 +234,15 @@ export const useSftpPaneActions = ({
|
||||
if (isSessionError(err)) {
|
||||
sftpSessionsRef.current.delete(pane.connection.id);
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: "SFTP session expired. Please reconnect.",
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
}));
|
||||
if (options?.tabId) {
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
error: "sftp.error.sessionLost",
|
||||
loading: false,
|
||||
}));
|
||||
} else {
|
||||
handleSessionError(side, err as Error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw err as Error;
|
||||
@@ -257,27 +250,15 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
|
||||
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) {
|
||||
// Side-level sequence was bumped by another tab's navigation or
|
||||
// a connect/disconnect. Check if THIS tab's request is still current.
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
|
||||
// This tab also has a newer navigation — drop completely.
|
||||
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;
|
||||
// Side was superseded by another tab, but this tab's request is
|
||||
// still current. The fetched files are valid — fall through to
|
||||
// apply them instead of restoring previousPath.
|
||||
}
|
||||
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
@@ -285,14 +266,14 @@ export const useSftpPaneActions = ({
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
lastConfirmedRef.current.set(targetTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
@@ -311,24 +292,13 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== 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;
|
||||
// Side superseded by another tab, but this tab's request is
|
||||
// current — fall through to show the error on this tab.
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
updateTab(side, targetTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
@@ -358,16 +328,24 @@ export const useSftpPaneActions = ({
|
||||
listRemoteFiles,
|
||||
sftpSessionsRef,
|
||||
clearCacheForConnection,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
const pane = getActivePane(side);
|
||||
async (side: "left" | "right", options?: { tabId?: string }) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const pane = options?.tabId
|
||||
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||||
: getActivePane(side);
|
||||
if (pane?.connection) {
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true });
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
|
||||
} else if (!pane?.connection && pane?.error) {
|
||||
// For background tabs, don't trigger reconnection (it operates on
|
||||
// the active tab). Just leave the error state for the user to see
|
||||
// when they switch back to that tab.
|
||||
if (options?.tabId) return;
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
@@ -384,7 +362,7 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[getActivePane, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
|
||||
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
|
||||
);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
@@ -405,42 +383,24 @@ export const useSftpPaneActions = ({
|
||||
|
||||
const openEntry = useCallback(
|
||||
async (side: "left" | "right", entry: SftpFileEntry) => {
|
||||
console.log("[SFTP openEntry] called", { side, entryName: entry.name, entryType: entry.type });
|
||||
|
||||
const pane = getActivePane(side);
|
||||
console.log("[SFTP openEntry] getActivePane result", {
|
||||
paneId: pane?.id,
|
||||
hasConnection: !!pane?.connection,
|
||||
currentPath: pane?.connection?.currentPath,
|
||||
});
|
||||
|
||||
if (!pane?.connection) {
|
||||
console.log("[SFTP openEntry] No pane or connection, returning early");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.name === "..") {
|
||||
const currentPath = pane.connection.currentPath;
|
||||
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
|
||||
console.log("[SFTP openEntry] Navigating up from '..'", {
|
||||
currentPath,
|
||||
isAtRoot,
|
||||
isWindowsRoot: isWindowsRoot(currentPath),
|
||||
});
|
||||
|
||||
if (!isAtRoot) {
|
||||
const parentPath = getParentPath(currentPath);
|
||||
console.log("[SFTP openEntry] Calculated parent path", { currentPath, parentPath });
|
||||
await navigateTo(side, parentPath);
|
||||
} else {
|
||||
console.log("[SFTP openEntry] Already at root, not navigating");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNavigableDirectory(entry)) {
|
||||
const newPath = joinPath(pane.connection.currentPath, entry.name);
|
||||
console.log("[SFTP openEntry] Navigating into directory", { currentPath: pane.connection.currentPath, entryName: entry.name, newPath });
|
||||
await navigateTo(side, newPath);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { createEmptyPane, EMPTY_LEFT_PANE_ID, EMPTY_RIGHT_PANE_ID, SftpPane, SftpSideTabs } from "./types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
export interface SftpTabsState {
|
||||
interface SftpTabsState {
|
||||
leftTabs: SftpSideTabs;
|
||||
rightTabs: SftpSideTabs;
|
||||
leftTabsRef: React.MutableRefObject<SftpSideTabs>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FileConflict,
|
||||
SftpFileEntry,
|
||||
@@ -14,7 +14,7 @@ import { joinPath } from "./utils";
|
||||
|
||||
interface UseSftpTransfersParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
@@ -64,66 +64,10 @@ export const useSftpTransfers = ({
|
||||
const [transfers, setTransfers] = useState<TransferTask[]>([]);
|
||||
const [conflicts, setConflicts] = useState<FileConflict[]>([]);
|
||||
|
||||
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
// Track cancelled task IDs for checking during async operations
|
||||
const cancelledTasksRef = useRef<Set<string>>(new Set());
|
||||
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const intervalsRef = progressIntervalsRef.current;
|
||||
return () => {
|
||||
intervalsRef.forEach((interval) => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
intervalsRef.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startProgressSimulation = useCallback(
|
||||
(taskId: string, estimatedBytes: number) => {
|
||||
const existing = progressIntervalsRef.current.get(taskId);
|
||||
if (existing) clearInterval(existing);
|
||||
|
||||
const baseSpeed = Math.max(50000, Math.min(500000, estimatedBytes / 10));
|
||||
const variability = 0.3;
|
||||
|
||||
let transferred = 0;
|
||||
const interval = setInterval(() => {
|
||||
const speedFactor = 1 + (Math.random() - 0.5) * variability;
|
||||
const chunkSize = Math.floor(baseSpeed * speedFactor * 0.1);
|
||||
transferred = Math.min(transferred + chunkSize, estimatedBytes);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== taskId || t.status !== "transferring") return t;
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: estimatedBytes,
|
||||
speed: chunkSize * 10,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (transferred >= estimatedBytes * 0.95) {
|
||||
clearInterval(interval);
|
||||
progressIntervalsRef.current.delete(taskId);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
progressIntervalsRef.current.set(taskId, interval);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stopProgressSimulation = useCallback((taskId: string) => {
|
||||
const interval = progressIntervalsRef.current.get(taskId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
progressIntervalsRef.current.delete(taskId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearCancelledTask = useCallback((taskId: string) => {
|
||||
cancelledTasksRef.current.delete(taskId);
|
||||
}, []);
|
||||
@@ -207,114 +151,64 @@ export const useSftpTransfers = ({
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (netcattyBridge.get()?.startStreamTransfer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
sourceSftpId: sourceSftpId || undefined,
|
||||
targetSftpId: targetSftpId || undefined,
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
|
||||
targetEncoding: targetIsLocal ? undefined : targetEncoding,
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
sourceSftpId: sourceSftpId || undefined,
|
||||
targetSftpId: targetSftpId || undefined,
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
|
||||
targetEncoding: targetIsLocal ? undefined : targetEncoding,
|
||||
};
|
||||
|
||||
const onProgress = (
|
||||
transferred: number,
|
||||
total: number,
|
||||
speed: number,
|
||||
) => {
|
||||
// Bubble up streaming progress to parent (for directory transfers)
|
||||
onStreamProgress?.(transferred, total, speed);
|
||||
const onProgress = (
|
||||
transferred: number,
|
||||
total: number,
|
||||
speed: number,
|
||||
) => {
|
||||
// Bubble up streaming progress to parent (for directory transfers)
|
||||
onStreamProgress?.(transferred, total, speed);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
if (t.status === "cancelled") return t;
|
||||
const normalizedTotal = total > 0 ? total : t.totalBytes;
|
||||
const normalizedTransferred = Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
|
||||
);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: normalizedTransferred,
|
||||
totalBytes: normalizedTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onComplete = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
reject(new Error(error));
|
||||
};
|
||||
|
||||
netcattyBridge.require().startStreamTransfer!(
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
let content: ArrayBuffer | string;
|
||||
|
||||
if (sourceIsLocal) {
|
||||
content =
|
||||
(await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
|
||||
new ArrayBuffer(0);
|
||||
} else if (sourceSftpId) {
|
||||
if (netcattyBridge.get()?.readSftpBinary) {
|
||||
content = await netcattyBridge.get()!.readSftpBinary!(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
if (t.status === "cancelled") return t;
|
||||
const normalizedTotal = total > 0 ? total : t.totalBytes;
|
||||
// Clamp to [previous, total] — the backend normalizes progress
|
||||
// but we guard against any non-monotonic edge cases.
|
||||
const normalizedTransferred = Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
|
||||
);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: normalizedTransferred,
|
||||
totalBytes: normalizedTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
content =
|
||||
(await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath, sourceEncoding)) || "";
|
||||
}
|
||||
} else {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
};
|
||||
|
||||
if (targetIsLocal) {
|
||||
if (content instanceof ArrayBuffer) {
|
||||
await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
|
||||
} else {
|
||||
const encoder = new TextEncoder();
|
||||
await netcattyBridge.get()?.writeLocalFile?.(
|
||||
task.targetPath,
|
||||
encoder.encode(content).buffer,
|
||||
);
|
||||
}
|
||||
} else if (targetSftpId) {
|
||||
if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
|
||||
await netcattyBridge.get()!.writeSftpBinary!(
|
||||
targetSftpId,
|
||||
task.targetPath,
|
||||
content,
|
||||
targetEncoding,
|
||||
);
|
||||
} else {
|
||||
const text =
|
||||
content instanceof ArrayBuffer
|
||||
? new TextDecoder().decode(content)
|
||||
: content;
|
||||
await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text, targetEncoding);
|
||||
}
|
||||
} else {
|
||||
throw new Error("No target connection");
|
||||
}
|
||||
const onComplete = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
reject(new Error(error));
|
||||
};
|
||||
|
||||
netcattyBridge.require().startStreamTransfer!(
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
const transferDirectory = async (
|
||||
@@ -456,6 +350,7 @@ export const useSftpTransfers = ({
|
||||
// Fall back to the existing estimate below if size discovery fails.
|
||||
}
|
||||
} else if (actualFileSize === 0) {
|
||||
// Fallback stat when file wasn't in the pane's file list (e.g., filtered view)
|
||||
try {
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
@@ -463,14 +358,24 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (sourcePane.connection?.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
if (stat) actualFileSize = stat.size;
|
||||
if (stat) {
|
||||
actualFileSize = stat.size;
|
||||
if (!task.sourceLastModified && stat.lastModified) {
|
||||
task.sourceLastModified = stat.lastModified;
|
||||
}
|
||||
}
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) actualFileSize = stat.size;
|
||||
if (stat) {
|
||||
actualFileSize = stat.size;
|
||||
if (!task.sourceLastModified && stat.lastModified) {
|
||||
task.sourceLastModified = stat.lastModified;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore stat errors
|
||||
@@ -484,7 +389,6 @@ export const useSftpTransfers = ({
|
||||
? 1024 * 1024
|
||||
: 256 * 1024;
|
||||
|
||||
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
|
||||
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
@@ -504,8 +408,6 @@ export const useSftpTransfers = ({
|
||||
throw new Error("Target SFTP session not found");
|
||||
}
|
||||
|
||||
let useSimulatedProgress = false;
|
||||
|
||||
try {
|
||||
if (prescanCancelled) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -518,41 +420,14 @@ export const useSftpTransfers = ({
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
if (!hasStreamingTransfer && !task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
|
||||
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
|
||||
let targetExists = false;
|
||||
let existingStat: { size: number; mtime: number } | null = null;
|
||||
let sourceStat: { size: number; mtime: number } | null = null;
|
||||
|
||||
try {
|
||||
if (sourcePane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
if (stat) {
|
||||
sourceStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
sourceStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Use cached metadata from the task instead of an extra stat round-trip
|
||||
const sourceStat: { size: number; mtime: number } | null =
|
||||
(task.totalBytes > 0 || task.sourceLastModified)
|
||||
? { size: task.totalBytes, mtime: task.sourceLastModified || Date.now() }
|
||||
: null;
|
||||
|
||||
try {
|
||||
if (targetPane.connection.isLocal) {
|
||||
@@ -583,8 +458,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
if (targetExists && existingStat) {
|
||||
stopProgressSimulation(task.id);
|
||||
|
||||
const newConflict: FileConflict = {
|
||||
transferId: task.id,
|
||||
fileName: task.fileName,
|
||||
@@ -654,10 +527,6 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
}
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
@@ -671,7 +540,9 @@ export const useSftpTransfers = ({
|
||||
}),
|
||||
);
|
||||
|
||||
await refresh(targetSide);
|
||||
// Refresh the specific target tab, not whichever tab happens to be
|
||||
// active now — focus may have switched during the transfer.
|
||||
await refresh(targetSide, { tabId: targetPane.id });
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
@@ -687,10 +558,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
return "completed";
|
||||
} catch (err) {
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
}
|
||||
|
||||
// Check if this was a cancellation
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id) ||
|
||||
(err instanceof Error && err.message === "Transfer cancelled");
|
||||
@@ -754,18 +621,10 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return [];
|
||||
|
||||
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
|
||||
? "auto"
|
||||
: sourcePane.filenameEncoding || "auto";
|
||||
|
||||
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
|
||||
const targetPath = targetPane.connection.currentPath;
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
|
||||
const sourceSftpId = sourcePane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourceConnectionId);
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
@@ -776,25 +635,11 @@ export const useSftpTransfers = ({
|
||||
? "download"
|
||||
: "remote-to-remote";
|
||||
|
||||
let fileSize = 0;
|
||||
if (!file.isDirectory) {
|
||||
try {
|
||||
const fullPath = joinPath(sourcePath, file.name);
|
||||
if (sourcePane.connection!.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
|
||||
if (stat) fileSize = stat.size;
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
fullPath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) fileSize = stat.size;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// Use cached metadata from the source pane's file list to avoid
|
||||
// redundant stat calls over the network.
|
||||
const fileEntry = sourcePane.files.find((f) => f.name === file.name);
|
||||
const fileSize = file.isDirectory ? 0 : (fileEntry?.size ?? 0);
|
||||
const sourceLastModified = fileEntry?.lastModified ?? 0;
|
||||
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -811,6 +656,7 @@ export const useSftpTransfers = ({
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: file.isDirectory,
|
||||
sourceLastModified,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -845,8 +691,6 @@ export const useSftpTransfers = ({
|
||||
// Add to cancelled set so async operations can check
|
||||
cancelledTasksRef.current.add(transferId);
|
||||
|
||||
stopProgressSimulation(transferId);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
@@ -870,7 +714,7 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
},
|
||||
[stopProgressSimulation],
|
||||
[],
|
||||
);
|
||||
|
||||
const retryTransfer = useCallback(
|
||||
|
||||
@@ -52,35 +52,27 @@ export const joinPath = (base: string, name: string): string => {
|
||||
};
|
||||
|
||||
export const getParentPath = (path: string): string => {
|
||||
console.log("[SFTP getParentPath] input", { path, isWindows: isWindowsPath(path) });
|
||||
|
||||
if (isWindowsPath(path)) {
|
||||
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
|
||||
const drive = normalized.slice(0, 2);
|
||||
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
|
||||
console.log("[SFTP getParentPath] Windows root, returning", { result: `${drive}\\` });
|
||||
return `${drive}\\`;
|
||||
}
|
||||
const rest = normalized.slice(2).replace(/^[\\]+/, "");
|
||||
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
|
||||
if (parts.length <= 1) {
|
||||
console.log("[SFTP getParentPath] Windows near root, returning", { result: `${drive}\\` });
|
||||
return `${drive}\\`;
|
||||
}
|
||||
parts.pop();
|
||||
const result = `${drive}\\${parts.join("\\")}`;
|
||||
console.log("[SFTP getParentPath] Windows result", { result });
|
||||
return result;
|
||||
}
|
||||
if (path === "/") {
|
||||
console.log("[SFTP getParentPath] Unix root, returning /");
|
||||
return "/";
|
||||
}
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
console.log("[SFTP getParentPath] Unix parts before pop", { parts: [...parts] });
|
||||
parts.pop();
|
||||
const result = parts.length ? `/${parts.join("/")}` : "/";
|
||||
console.log("[SFTP getParentPath] Unix result", { result, partsAfterPop: parts });
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
@@ -218,7 +218,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (!connectedProvider) return;
|
||||
|
||||
try {
|
||||
console.log('[AutoSync] Checking remote version...');
|
||||
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
|
||||
const base = await manager.loadSyncBase(connectedProvider);
|
||||
const remotePayload = await sync.downloadFromProvider(connectedProvider);
|
||||
@@ -228,7 +227,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const localPayload = buildPayload();
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
console.log('[AutoSync] Remote is newer, merged:', mergeResult.summary);
|
||||
config.onApplyPayload(mergeResult.payload);
|
||||
// Don't save base or skip auto-sync — let the data-change effect
|
||||
// naturally trigger an upload of the merged payload (which will
|
||||
@@ -282,7 +280,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Debounce sync by 3 seconds
|
||||
syncTimeoutRef.current = setTimeout(() => {
|
||||
console.log('[AutoSync] Data changed, syncing...');
|
||||
syncNow();
|
||||
}, 3000);
|
||||
|
||||
|
||||
@@ -103,8 +103,6 @@ export const useManagedSourceSync = ({
|
||||
|
||||
const writeSshConfigToFile = useCallback(
|
||||
async (source: ManagedSource, managedHosts: Host[]) => {
|
||||
console.log(`[ManagedSourceSync] writeSshConfigToFile called for ${source.groupName}, hosts:`, managedHosts.length);
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeLocalFile) {
|
||||
console.warn("[ManagedSourceSync] writeLocalFile not available");
|
||||
@@ -121,14 +119,9 @@ export const useManagedSourceSync = ({
|
||||
managedHosts,
|
||||
hosts,
|
||||
);
|
||||
console.log(`[ManagedSourceSync] Final content (${finalContent.length} chars)`);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const buffer = encoder.encode(finalContent);
|
||||
console.log(`[ManagedSourceSync] Writing to ${source.filePath}`);
|
||||
|
||||
await bridge.writeLocalFile(source.filePath, buffer.buffer as ArrayBuffer);
|
||||
console.log(`[ManagedSourceSync] Write successful`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[ManagedSourceSync] Failed to write SSH config:", err);
|
||||
@@ -159,12 +152,8 @@ export const useManagedSourceSync = ({
|
||||
// This should be called before deleting a managed group to avoid stale entries
|
||||
const clearAndRemoveSource = useCallback(
|
||||
async (source: ManagedSource) => {
|
||||
console.log(`[ManagedSourceSync] Clearing managed block for ${source.groupName}`);
|
||||
// Write empty hosts list to clear the managed block
|
||||
const success = await writeSshConfigToFile(source, []);
|
||||
if (success) {
|
||||
console.log(`[ManagedSourceSync] Managed block cleared, removing source`);
|
||||
}
|
||||
// Remove the source regardless of write success
|
||||
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== source.id);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
@@ -179,19 +168,14 @@ export const useManagedSourceSync = ({
|
||||
async (sources: ManagedSource[]) => {
|
||||
if (sources.length === 0) return;
|
||||
|
||||
console.log(`[ManagedSourceSync] Clearing ${sources.length} managed blocks`);
|
||||
|
||||
// Clear all files in parallel
|
||||
const results = await Promise.all(
|
||||
await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
const success = await writeSshConfigToFile(source, []);
|
||||
return { sourceId: source.id, success };
|
||||
})
|
||||
);
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
console.log(`[ManagedSourceSync] Cleared ${successCount}/${sources.length} managed blocks`);
|
||||
|
||||
// Remove all sources atomically in a single update
|
||||
const sourceIdsToRemove = new Set(sources.map(s => s.id));
|
||||
const updatedSources = managedSourcesRef.current.filter(
|
||||
@@ -273,8 +257,6 @@ export const useManagedSourceSync = ({
|
||||
const prevManaged = prevHostsBySource.get(source.id) || [];
|
||||
const currManaged = currHostsBySource.get(source.id) || [];
|
||||
|
||||
console.log(`[ManagedSourceSync] Source ${source.groupName}: prev=${prevManaged.length}, curr=${currManaged.length}`);
|
||||
|
||||
if (prevManaged.length !== currManaged.length) {
|
||||
changedSourceIds.add(source.id);
|
||||
continue;
|
||||
@@ -328,7 +310,6 @@ export const useManagedSourceSync = ({
|
||||
}
|
||||
|
||||
if (changedSourceIds.size > 0) {
|
||||
console.log(`[ManagedSourceSync] Syncing sources:`, Array.from(changedSourceIds));
|
||||
syncInProgressRef.current = true;
|
||||
|
||||
Promise.all(
|
||||
|
||||
@@ -216,9 +216,7 @@ export const useSftpBackend = () => {
|
||||
}
|
||||
|
||||
// Download the file to temp
|
||||
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
|
||||
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName, options?.encoding);
|
||||
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
|
||||
|
||||
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
|
||||
if (bridge.registerTempFile) {
|
||||
@@ -230,25 +228,18 @@ export const useSftpBackend = () => {
|
||||
}
|
||||
|
||||
// Open with the selected application
|
||||
console.log("[SFTPBackend] Opening with application", { tempPath, appPath });
|
||||
await bridge.openWithApplication(tempPath, appPath);
|
||||
console.log("[SFTPBackend] Application launched");
|
||||
|
||||
// Start file watching if enabled
|
||||
let watchId: string | undefined;
|
||||
console.log("[SFTPBackend] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
|
||||
if (options?.enableWatch && bridge.startFileWatch) {
|
||||
try {
|
||||
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
|
||||
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId, options?.encoding);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
|
||||
} catch (err) {
|
||||
console.warn("[SFTPBackend] Failed to start file watch:", err);
|
||||
// Don't fail the operation if watching fails
|
||||
}
|
||||
} else {
|
||||
console.log("[SFTPBackend] File watching not enabled or not available");
|
||||
}
|
||||
|
||||
return { localTempPath: tempPath, watchId };
|
||||
|
||||
@@ -25,7 +25,6 @@ let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
|
||||
|
||||
function loadFromStorage(): FileAssociationsMap {
|
||||
const stored = localStorageAdapter.read<FileAssociationsMap>(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
|
||||
console.log('[SftpFileAssociations] Loading from storage:', stored);
|
||||
if (stored) {
|
||||
const migrated: FileAssociationsMap = {};
|
||||
for (const [ext, value] of Object.entries(stored)) {
|
||||
@@ -35,7 +34,6 @@ function loadFromStorage(): FileAssociationsMap {
|
||||
migrated[ext] = value as FileAssociationEntry;
|
||||
}
|
||||
}
|
||||
console.log('[SftpFileAssociations] Migrated associations:', migrated);
|
||||
return migrated;
|
||||
}
|
||||
return {};
|
||||
@@ -45,19 +43,13 @@ function loadFromStorage(): FileAssociationsMap {
|
||||
snapshotRef = { associations: loadFromStorage() };
|
||||
|
||||
function saveToStorage(associations: FileAssociationsMap) {
|
||||
console.log('[SftpFileAssociations] saveToStorage called with:', associations);
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations);
|
||||
// Verify it was saved
|
||||
const verify = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
|
||||
console.log('[SftpFileAssociations] Verification read from storage:', verify);
|
||||
}
|
||||
|
||||
function updateAssociations(newAssociations: FileAssociationsMap) {
|
||||
console.log('[SftpFileAssociations] Updating associations:', newAssociations);
|
||||
// Create new reference so useSyncExternalStore detects change
|
||||
snapshotRef = { associations: newAssociations };
|
||||
saveToStorage(newAssociations);
|
||||
console.log('[SftpFileAssociations] Notifying', subscribers.size, 'subscribers');
|
||||
subscribers.forEach(callback => callback());
|
||||
}
|
||||
|
||||
@@ -101,8 +93,6 @@ export function useSftpFileAssociations() {
|
||||
openerType: FileOpenerType,
|
||||
systemApp?: SystemAppInfo
|
||||
) => {
|
||||
console.log('[SftpFileAssociations] setOpenerForExtension called with:', { extension, openerType, systemApp });
|
||||
console.log('[SftpFileAssociations] Current associations before update:', snapshotRef.associations);
|
||||
updateAssociations({
|
||||
...snapshotRef.associations,
|
||||
[extension.toLowerCase()]: { openerType, systemApp },
|
||||
@@ -122,13 +112,11 @@ export function useSftpFileAssociations() {
|
||||
* Get all associations as an array
|
||||
*/
|
||||
const getAllAssociations = useCallback((): FileAssociation[] => {
|
||||
const result = Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
|
||||
return Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
|
||||
extension,
|
||||
openerType: entry.openerType,
|
||||
systemApp: entry.systemApp,
|
||||
}));
|
||||
console.log('[SftpFileAssociations] getAllAssociations called, returning', result.length, 'items:', result);
|
||||
return result;
|
||||
}, [associations]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,12 +16,8 @@ const STARTUP_CHECK_DELAY_MS = 8000;
|
||||
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
|
||||
// Debug logging for update checks
|
||||
const debugLog = (...args: unknown[]) => {
|
||||
if (IS_UPDATE_DEMO_MODE || (typeof window !== 'undefined' && window.localStorage?.getItem('debug.updateCheck') === '1')) {
|
||||
console.log('[UpdateCheck]', ...args);
|
||||
}
|
||||
};
|
||||
// Debug logging for update checks (no-op in production)
|
||||
const debugLog = (..._args: unknown[]) => {};
|
||||
|
||||
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
|
||||
@@ -411,7 +411,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { console.log('[ProviderCard] Connect clicked'); onConnect(); }}
|
||||
onClick={() => { onConnect(); }}
|
||||
className="gap-1"
|
||||
disabled={disabled || isConnecting}
|
||||
>
|
||||
@@ -689,15 +689,6 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Debug: log provider states
|
||||
console.log('[SyncDashboard] Provider states:', {
|
||||
github: sync.providers.github.status,
|
||||
google: sync.providers.google.status,
|
||||
onedrive: sync.providers.onedrive.status,
|
||||
webdav: sync.providers.webdav.status,
|
||||
s3: sync.providers.s3.status,
|
||||
});
|
||||
|
||||
// GitHub Device Flow state
|
||||
const [showGitHubModal, setShowGitHubModal] = useState(false);
|
||||
const [gitHubUserCode, setGitHubUserCode] = useState('');
|
||||
@@ -789,12 +780,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
// Connect GitHub (disconnect others first - single provider only)
|
||||
const handleConnectGitHub = async () => {
|
||||
console.log('[CloudSync] handleConnectGitHub called');
|
||||
try {
|
||||
await disconnectOtherProviders('github');
|
||||
console.log('[CloudSync] Calling sync.connectGitHub()...');
|
||||
const deviceFlow = await sync.connectGitHub();
|
||||
console.log('[CloudSync] Device flow received:', deviceFlow.userCode);
|
||||
setGitHubUserCode(deviceFlow.userCode);
|
||||
setGitHubVerificationUri(deviceFlow.verificationUri);
|
||||
setShowGitHubModal(true);
|
||||
|
||||
@@ -45,7 +45,6 @@ export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
try {
|
||||
const result = await onSelectSystemApp();
|
||||
if (result) {
|
||||
console.log('[FileOpenerDialog] Calling onSelect with rememberChoice:', rememberChoice, 'result:', result);
|
||||
onSelect('system-app', rememberChoice, result);
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -127,8 +127,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
os: "linux",
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
theme: terminalThemeId,
|
||||
fontSize: terminalFontSize,
|
||||
distroMode: "auto",
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
|
||||
@@ -194,17 +194,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
// Maps tab IDs to the connectionKey used to create them, so we can
|
||||
// correctly identify tabs when the same host ID has different overrides.
|
||||
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
|
||||
const pendingConnectionKeyRef = useRef<string | null>(null);
|
||||
const prevIsVisibleRef = useRef(isVisible);
|
||||
|
||||
// Reset location guard when the panel is reopened so the terminal cwd
|
||||
// is re-applied even if it matches the previous session's path.
|
||||
useEffect(() => {
|
||||
if (isVisible && !prevIsVisibleRef.current) {
|
||||
lastAppliedInitialLocationKeyRef.current = null;
|
||||
}
|
||||
prevIsVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
// NOTE: We intentionally do NOT reset lastAppliedInitialLocationKeyRef on
|
||||
// visibility changes. When the user switches terminal tabs, the panel
|
||||
// toggles isVisible but should preserve its navigation state (the user may
|
||||
// have navigated away from initialLocation). When the panel is truly
|
||||
// closed, the component unmounts and all refs are naturally reset.
|
||||
|
||||
// Navigate SFTP to the terminal's current working directory
|
||||
const handleGoToTerminalCwd = useCallback(async () => {
|
||||
@@ -217,14 +212,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
// Track whether there's active work that should block connection switching.
|
||||
// Computed outside the effect so it can be in the dependency array.
|
||||
const hasActiveTransfers = useMemo(
|
||||
() => sftp.transfers.some((t) => t.status === "pending" || t.status === "transferring"),
|
||||
[sftp.transfers],
|
||||
);
|
||||
// Block host-following while any connection-sensitive UI or operation
|
||||
// is active: text editor, permissions dialog, file-opener dialog, or
|
||||
// Block host-following while any connection-sensitive interactive UI is
|
||||
// active: text editor, permissions dialog, file-opener dialog, or
|
||||
// auto-synced external file watches.
|
||||
const hasActiveWork = hasActiveTransfers || showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
// Note: transfers are NOT included here — they run on their own sftpId
|
||||
// independent of the active tab, and forceNewTab preserves old connections.
|
||||
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -309,28 +302,24 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new tab when there's already an active connection to a different
|
||||
// host, so the previous tab is preserved for instant switching on focus change.
|
||||
// Create a new tab when there's already an active connection, so the
|
||||
// previous tab is preserved for instant switching on focus change.
|
||||
// This covers both different hosts AND same host with different
|
||||
// session-time overrides (port/protocol), preventing the old SFTP
|
||||
// session from being closed while it may have in-flight transfers.
|
||||
const currentConn = s.leftPane.connection;
|
||||
const needsNewTab = !!(currentConn && currentConn.status === "connected" && currentConn.hostId !== activeHost.id);
|
||||
const needsNewTab = !!(currentConn && currentConn.status === "connected");
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
// Store the pending key so the effect below can map it once the tab is created
|
||||
pendingConnectionKeyRef.current = connectionKey;
|
||||
s.connect("left", activeHost, needsNewTab ? { forceNewTab: true } : undefined);
|
||||
s.connect("left", activeHost, {
|
||||
...(needsNewTab ? { forceNewTab: true } : undefined),
|
||||
onTabCreated: (tabId) => {
|
||||
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
||||
},
|
||||
});
|
||||
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
|
||||
|
||||
// Track the active tab's connectionKey after connect() creates or reuses it.
|
||||
// Watches both activeTabId (new tab) and connection status (reused tab reconnecting).
|
||||
useEffect(() => {
|
||||
const activeTabId = sftp.leftTabs.activeTabId;
|
||||
if (activeTabId && pendingConnectionKeyRef.current) {
|
||||
tabConnectionKeyMapRef.current.set(activeTabId, pendingConnectionKeyRef.current);
|
||||
pendingConnectionKeyRef.current = null;
|
||||
}
|
||||
}, [sftp.leftTabs.activeTabId, sftp.leftPane.connection?.status]);
|
||||
|
||||
// Clear the remembered connection key when the pane disconnects or the
|
||||
// session is lost, so re-opening SFTP for the same terminal reconnects.
|
||||
// Also reset the file-watch counter — watches are bound to the SFTP session,
|
||||
@@ -436,10 +425,19 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
]);
|
||||
|
||||
const MAX_VISIBLE_TRANSFERS = 5;
|
||||
const visibleTransfers = useMemo(
|
||||
() => [...sftp.transfers].reverse().slice(0, MAX_VISIBLE_TRANSFERS),
|
||||
[sftp.transfers],
|
||||
);
|
||||
const visibleTransfers = useMemo(() => {
|
||||
const connection = sftp.leftPane.connection;
|
||||
if (!connection) return [];
|
||||
// Filter transfers to those relevant to the active connection's host,
|
||||
// so workspace focus switches don't show transfers from other hosts.
|
||||
const filtered = sftp.transfers.filter((t) => {
|
||||
if (connection.isLocal) {
|
||||
return t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
|
||||
}
|
||||
return t.targetHostId === connection.hostId || t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
|
||||
});
|
||||
return [...filtered].reverse().slice(0, MAX_VISIBLE_TRANSFERS);
|
||||
}, [sftp.transfers, sftp.leftPane.connection]);
|
||||
|
||||
const handleRevealTransferTarget = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
|
||||
@@ -571,7 +571,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
if (isManaged && (newHosts.length > 0 || updatedExistingHosts.length > 0)) {
|
||||
const sourceId = crypto.randomUUID();
|
||||
console.log('[Import] File path resolved:', filePath);
|
||||
const newSource: ManagedSource = {
|
||||
id: sourceId,
|
||||
type: "ssh_config",
|
||||
|
||||
@@ -33,9 +33,6 @@ export default function SettingsFileAssociationsTab() {
|
||||
const associations = getAllAssociations();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
|
||||
// Debug log for Settings page
|
||||
console.log('[SettingsFileAssociationsTab] Rendering with', associations.length, 'associations:', associations);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
removeAssociation(extension);
|
||||
|
||||
@@ -119,18 +119,14 @@ export default function SettingsTerminalTab(props: {
|
||||
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
console.log('[Settings] No file selected');
|
||||
return;
|
||||
}
|
||||
console.log('[Settings] File selected:', file.name, 'size:', file.size);
|
||||
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const xml = reader.result as string;
|
||||
console.log('[Settings] File read successfully, length:', xml.length);
|
||||
const parsed = parseItermcolors(xml, name);
|
||||
if (parsed) {
|
||||
console.log('[Settings] Theme parsed successfully:', parsed.id, parsed.name);
|
||||
customThemeStore.addTheme(parsed);
|
||||
setTerminalThemeId(parsed.id);
|
||||
} else {
|
||||
|
||||
@@ -47,7 +47,6 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onSelect(entry, index, e);
|
||||
}, [entry, index, onSelect]);
|
||||
const handleOpen = useCallback(() => {
|
||||
console.log("[SftpFileRow] handleOpen called", { entryName: entry.name, entryType: entry.type });
|
||||
onOpen(entry);
|
||||
}, [entry, onOpen]);
|
||||
const handleDragStart = useCallback((e: React.DragEvent) => {
|
||||
|
||||
@@ -39,6 +39,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
const { t } = useI18n();
|
||||
const hasKnownTotal = task.totalBytes > 0;
|
||||
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
|
||||
// Show indeterminate state when transferring but no real progress received yet
|
||||
const isIndeterminate = task.status === 'transferring' && hasKnownTotal && task.transferredBytes === 0;
|
||||
|
||||
// Calculate remaining time from backend-reported sliding-window speed
|
||||
const remainingBytes = task.totalBytes - task.transferredBytes;
|
||||
@@ -82,10 +84,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] leading-5 truncate font-medium">{task.fileName}</span>
|
||||
{task.status === 'transferring' && speedFormatted && (
|
||||
{task.status === 'transferring' && !isIndeterminate && speedFormatted && (
|
||||
<span className="text-[10px] text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
|
||||
)}
|
||||
{task.status === 'transferring' && remainingFormatted && (
|
||||
{task.status === 'transferring' && !isIndeterminate && remainingFormatted && (
|
||||
<span className="text-[10px] text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -106,10 +108,12 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
"h-full rounded-full relative overflow-hidden",
|
||||
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
|
||||
? "bg-muted-foreground/50 animate-pulse"
|
||||
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
|
||||
: isIndeterminate
|
||||
? "bg-primary/60 animate-pulse"
|
||||
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
|
||||
)}
|
||||
style={{
|
||||
width: task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
|
||||
width: task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
|
||||
? '100%'
|
||||
: `${progress}%`,
|
||||
transition: 'width 150ms ease-out'
|
||||
@@ -130,9 +134,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
|
||||
{task.status === 'pending'
|
||||
? 'waiting...'
|
||||
: hasKnownTotal
|
||||
? `${Math.round(progress)}%`
|
||||
: '...'}
|
||||
: isIndeterminate
|
||||
? t('sftp.transfer.preparing')
|
||||
: hasKnownTotal
|
||||
? `${Math.round(progress)}%`
|
||||
: '...'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type SftpClipboardOperation = "copy" | "cut";
|
||||
type SftpClipboardOperation = "copy" | "cut";
|
||||
|
||||
export interface SftpClipboardFile {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
export interface SftpClipboardState {
|
||||
interface SftpClipboardState {
|
||||
files: SftpClipboardFile[];
|
||||
sourcePath: string;
|
||||
sourceConnectionId: string;
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
import { useSyncExternalStore, useEffect } from "react";
|
||||
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
|
||||
|
||||
export type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
|
||||
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
|
||||
|
||||
export interface SftpDialogAction {
|
||||
interface SftpDialogAction {
|
||||
type: SftpDialogActionType;
|
||||
targetSide: SftpFocusedSide;
|
||||
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
|
||||
|
||||
@@ -27,9 +27,6 @@ export class KeywordHighlighter implements IDisposable {
|
||||
constructor(term: XTerm) {
|
||||
this.term = term;
|
||||
|
||||
// Debug logging
|
||||
console.log('[KeywordHighlighter] Initialized');
|
||||
|
||||
// Hook into terminal events to trigger highlighting
|
||||
this.disposables.push(
|
||||
// When user scrolls, refresh visible area
|
||||
|
||||
@@ -427,15 +427,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
try {
|
||||
const termEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
|
||||
|
||||
// DEBUG: Log key info for troubleshooting
|
||||
console.log("[Terminal] Starting SSH session with key info:", {
|
||||
keyId: key?.id,
|
||||
keyLabel: key?.label,
|
||||
keySource: key?.source,
|
||||
hasPublicKey: !!key?.publicKey,
|
||||
hasPrivateKey: !!key?.privateKey,
|
||||
});
|
||||
|
||||
const startAttempt = async (attempt: {
|
||||
password?: string;
|
||||
key?: SSHKey;
|
||||
|
||||
@@ -427,20 +427,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (terminalActions.has(action)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hotkeyDebug =
|
||||
import.meta.env.DEV &&
|
||||
typeof window !== "undefined" &&
|
||||
window.localStorage?.getItem("debug.hotkeys") === "1";
|
||||
if (hotkeyDebug) {
|
||||
console.log('[Hotkeys] Xterm terminal-level', {
|
||||
action,
|
||||
key: e.key,
|
||||
meta: e.metaKey,
|
||||
ctrl: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
});
|
||||
}
|
||||
switch (action) {
|
||||
case "copy": {
|
||||
const selection = term.getSelection();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SyncPayload } from "./sync";
|
||||
|
||||
export const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
|
||||
const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
|
||||
|
||||
/**
|
||||
* Base64 pattern: only allows A-Z, a-z, 0-9, +, / and trailing = padding.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Proxy configuration for SSH connections
|
||||
export type ProxyType = 'http' | 'socks5';
|
||||
type ProxyType = 'http' | 'socks5';
|
||||
// UI locale identifier, stored in settings and used for i18n (e.g., "en", "zh-CN").
|
||||
export type UILanguage = string;
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface SerialConfig {
|
||||
}
|
||||
|
||||
// Per-protocol configuration
|
||||
export interface ProtocolConfig {
|
||||
interface ProtocolConfig {
|
||||
protocol: HostProtocol;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
@@ -116,9 +116,9 @@ export interface Host {
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
export type KeySource = 'generated' | 'imported';
|
||||
type KeySource = 'generated' | 'imported';
|
||||
export type KeyCategory = 'key' | 'certificate' | 'identity';
|
||||
export type IdentityAuthMethod = 'password' | 'key' | 'certificate';
|
||||
type IdentityAuthMethod = 'password' | 'key' | 'certificate';
|
||||
|
||||
export interface SSHKey {
|
||||
id: string;
|
||||
@@ -157,13 +157,6 @@ export interface Snippet {
|
||||
noAutoRun?: boolean; // If true, paste command without executing (no trailing Enter)
|
||||
}
|
||||
|
||||
export interface TerminalLine {
|
||||
type: 'input' | 'output' | 'error' | 'system';
|
||||
content: string;
|
||||
directory?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'model';
|
||||
text: string;
|
||||
@@ -451,11 +444,11 @@ export interface TerminalSettings {
|
||||
|
||||
const STRICT_IPV4_OCTET_PATTERN = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
|
||||
|
||||
export const URL_HIGHLIGHT_PATTERN =
|
||||
const URL_HIGHLIGHT_PATTERN =
|
||||
"(?:\\bhttps?:\\/\\/\\[[0-9A-Fa-f:.]+\\](?::\\d+)?(?:[/?#][^\\s<>\"'`]*)?(?<![.,;:!?\\)}])|\\b(?:https?:\\/\\/|www\\.)[^\\s<>\"'`]+(?<![.,;:!?\\])}]))";
|
||||
export const IPV4_HIGHLIGHT_PATTERN =
|
||||
const IPV4_HIGHLIGHT_PATTERN =
|
||||
`(?<![\\w.])(?<!\\bver\\s)(?<!\\bversion\\s)(?:${STRICT_IPV4_OCTET_PATTERN}\\.){3}${STRICT_IPV4_OCTET_PATTERN}(?![\\w.])`;
|
||||
export const MAC_ADDRESS_HIGHLIGHT_PATTERN =
|
||||
const MAC_ADDRESS_HIGHLIGHT_PATTERN =
|
||||
'\\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\\b';
|
||||
|
||||
export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
|
||||
@@ -472,7 +465,7 @@ const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlight
|
||||
patterns: [...rule.patterns],
|
||||
});
|
||||
|
||||
export const normalizeKeywordHighlightRules = (
|
||||
const normalizeKeywordHighlightRules = (
|
||||
rules?: KeywordHighlightRule[],
|
||||
): KeywordHighlightRule[] => {
|
||||
if (!rules || rules.length === 0) {
|
||||
@@ -522,7 +515,7 @@ export const normalizeTerminalSettings = (
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
scrollback: 10000,
|
||||
drawBoldInBrightColors: true,
|
||||
terminalEmulationType: 'xterm-256color',
|
||||
@@ -693,6 +686,7 @@ export interface TransferTask {
|
||||
isDirectory: boolean;
|
||||
childTasks?: string[]; // For directory transfers
|
||||
parentTaskId?: string;
|
||||
sourceLastModified?: number; // Cached from file list to avoid redundant stat
|
||||
skipConflictCheck?: boolean; // Skip conflict check for replace operations
|
||||
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
|
||||
}
|
||||
@@ -710,7 +704,7 @@ export interface FileConflict {
|
||||
|
||||
// Port Forwarding Types
|
||||
export type PortForwardingType = 'local' | 'remote' | 'dynamic';
|
||||
export type PortForwardingStatus = 'inactive' | 'connecting' | 'active' | 'error';
|
||||
type PortForwardingStatus = 'inactive' | 'connecting' | 'active' | 'error';
|
||||
|
||||
export interface PortForwardingRule {
|
||||
id: string;
|
||||
@@ -777,14 +771,8 @@ export interface ConnectionLog {
|
||||
// Session Logs Settings - for auto-saving terminal logs to local filesystem
|
||||
export type SessionLogFormat = 'txt' | 'raw' | 'html';
|
||||
|
||||
export interface SessionLogsSettings {
|
||||
enabled: boolean; // Whether auto-save is enabled
|
||||
directory: string; // Base directory for logs
|
||||
format: SessionLogFormat; // Log file format
|
||||
}
|
||||
|
||||
// Managed Source - external file that manages a group of hosts (e.g., ~/.ssh/config)
|
||||
export type ManagedSourceType = 'ssh_config';
|
||||
type ManagedSourceType = 'ssh_config';
|
||||
|
||||
export interface ManagedSource {
|
||||
id: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface QuickConnectTarget {
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface QuickConnectParseResult {
|
||||
interface QuickConnectParseResult {
|
||||
target: QuickConnectTarget | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Host, Identity, SSHKey } from "./models";
|
||||
|
||||
export type HostAuthMethod = "password" | "key" | "certificate";
|
||||
type HostAuthMethod = "password" | "key" | "certificate";
|
||||
|
||||
export type HostAuthOverride = {
|
||||
type HostAuthOverride = {
|
||||
authMethod?: HostAuthMethod;
|
||||
username?: string;
|
||||
password?: string;
|
||||
@@ -10,7 +10,7 @@ export type HostAuthOverride = {
|
||||
passphrase?: string;
|
||||
};
|
||||
|
||||
export type ResolvedHostAuth = {
|
||||
type ResolvedHostAuth = {
|
||||
identity?: Identity;
|
||||
authMethod: HostAuthMethod;
|
||||
username: string;
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface S3Config {
|
||||
/**
|
||||
* Provider-specific connection status
|
||||
*/
|
||||
export type ProviderConnectionStatus =
|
||||
type ProviderConnectionStatus =
|
||||
| 'disconnected'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
@@ -113,7 +113,7 @@ export interface ProviderConnection {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const hasProviderConnectionData = (
|
||||
const hasProviderConnectionData = (
|
||||
connection: Pick<ProviderConnection, 'tokens' | 'config'>,
|
||||
): boolean => Boolean(connection.tokens || connection.config);
|
||||
|
||||
@@ -208,17 +208,6 @@ export interface SyncPayload {
|
||||
// Encryption Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Key derivation parameters
|
||||
*/
|
||||
export interface KDFParams {
|
||||
algorithm: 'PBKDF2' | 'Argon2id';
|
||||
salt: Uint8Array;
|
||||
iterations?: number; // For PBKDF2 (default: 600000)
|
||||
memory?: number; // For Argon2 (KB)
|
||||
parallelism?: number; // For Argon2
|
||||
}
|
||||
|
||||
/**
|
||||
* Encryption result
|
||||
*/
|
||||
@@ -298,17 +287,6 @@ export interface ConflictInfo {
|
||||
remoteDeviceName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync manager configuration
|
||||
*/
|
||||
export interface SyncManagerConfig {
|
||||
autoSync: boolean;
|
||||
autoSyncInterval: number; // Minutes
|
||||
providers: CloudProvider[];
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync history record entry
|
||||
*/
|
||||
@@ -348,33 +326,6 @@ export interface PKCEChallenge {
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth token response
|
||||
*/
|
||||
export interface GoogleTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OneDrive/MSAL token response
|
||||
*/
|
||||
export interface OneDriveTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresOn: number;
|
||||
tokenType: string;
|
||||
scopes: string[];
|
||||
account?: {
|
||||
homeAccountId: string;
|
||||
username: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Types
|
||||
// ============================================================================
|
||||
@@ -502,19 +453,6 @@ export const formatLastSync = (timestamp?: number): string => {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get status color for sync state
|
||||
*/
|
||||
export const getSyncStatusColor = (status: ProviderConnectionStatus): string => {
|
||||
switch (status) {
|
||||
case 'connected': return 'text-green-500';
|
||||
case 'syncing': return 'text-blue-500';
|
||||
case 'error': return 'text-red-500';
|
||||
case 'connecting': return 'text-yellow-500';
|
||||
default: return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get status dot color class
|
||||
*/
|
||||
|
||||
@@ -25,13 +25,13 @@ import type { SyncPayload } from './sync';
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MergeSummary {
|
||||
interface MergeSummary {
|
||||
added: { local: number; remote: number };
|
||||
deleted: { local: number; remote: number };
|
||||
modified: { local: number; remote: number; conflicts: number };
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
interface MergeResult {
|
||||
payload: SyncPayload;
|
||||
/** True when both sides modified the same entity (resolved by preferring local) */
|
||||
hadConflicts: boolean;
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface SyncableVaultData {
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
export interface SyncPayloadImporters {
|
||||
interface SyncPayloadImporters {
|
||||
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
|
||||
importVaultData: (jsonString: string) => void;
|
||||
/** Import port-forwarding rules (lives outside the vault hook). */
|
||||
@@ -164,7 +164,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
* Apply synced settings to localStorage. Merges terminal settings
|
||||
* to preserve platform-specific fields.
|
||||
*/
|
||||
export function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
||||
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
||||
// Theme & Appearance
|
||||
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { Host } from './models';
|
||||
|
||||
type TerminalAppearanceDefaults = {
|
||||
themeId: string;
|
||||
fontFamilyId: string;
|
||||
fontSize: number;
|
||||
};
|
||||
|
||||
const hasLegacyStringValue = (value: string | undefined): boolean =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
@@ -53,14 +47,3 @@ export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, d
|
||||
export const resolveHostTerminalFontSize = (host: Host | null | undefined, defaultFontSize: number): number =>
|
||||
hasHostFontSizeOverride(host) && host?.fontSize != null ? host.fontSize : defaultFontSize;
|
||||
|
||||
export const resolveHostTerminalAppearance = (
|
||||
host: Host | null | undefined,
|
||||
defaults: TerminalAppearanceDefaults,
|
||||
) => ({
|
||||
themeId: resolveHostTerminalThemeId(host, defaults.themeId),
|
||||
fontFamilyId: resolveHostTerminalFontFamilyId(host, defaults.fontFamilyId),
|
||||
fontSize: resolveHostTerminalFontSize(host, defaults.fontSize),
|
||||
hasThemeOverride: hasHostThemeOverride(host),
|
||||
hasFontFamilyOverride: hasHostFontFamilyOverride(host),
|
||||
hasFontSizeOverride: hasHostFontSizeOverride(host),
|
||||
});
|
||||
|
||||
@@ -84,28 +84,28 @@ export type VaultImportFormat =
|
||||
| "securecrt"
|
||||
| "ssh_config";
|
||||
|
||||
export type VaultImportIssueLevel = "warning" | "error";
|
||||
type VaultImportIssueLevel = "warning" | "error";
|
||||
|
||||
export interface VaultImportIssue {
|
||||
interface VaultImportIssue {
|
||||
level: VaultImportIssueLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface VaultImportStats {
|
||||
interface VaultImportStats {
|
||||
parsed: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
duplicates: number;
|
||||
}
|
||||
|
||||
export interface VaultImportResult {
|
||||
interface VaultImportResult {
|
||||
hosts: Host[];
|
||||
groups: string[];
|
||||
issues: VaultImportIssue[];
|
||||
stats: VaultImportStats;
|
||||
}
|
||||
|
||||
export interface VaultCsvTemplateOptions {
|
||||
interface VaultCsvTemplateOptions {
|
||||
includeExampleRows?: boolean;
|
||||
}
|
||||
|
||||
@@ -998,7 +998,7 @@ export const getVaultCsvTemplate = (
|
||||
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
|
||||
};
|
||||
|
||||
export const exportHostsToCsv = (hosts: Host[]): string => {
|
||||
const exportHostsToCsv = (hosts: Host[]): string => {
|
||||
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
|
||||
const rows: string[][] = [header];
|
||||
|
||||
@@ -1053,7 +1053,7 @@ export const exportHostsToCsv = (hosts: Host[]): string => {
|
||||
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
|
||||
};
|
||||
|
||||
export interface ExportHostsResult {
|
||||
interface ExportHostsResult {
|
||||
csv: string;
|
||||
exportedCount: number;
|
||||
skippedCount: number;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Workspace,WorkspaceNode,WorkspaceViewMode } from './models';
|
||||
|
||||
export type SplitDirection = 'horizontal' | 'vertical';
|
||||
export type SplitPosition = 'left' | 'right' | 'top' | 'bottom';
|
||||
type SplitPosition = 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
export type SplitHint = {
|
||||
direction: SplitDirection;
|
||||
|
||||
@@ -6,6 +6,7 @@ module.exports = {
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
icon: 'public/icon.png',
|
||||
npmRebuild: false,
|
||||
directories: {
|
||||
buildResources: 'build',
|
||||
output: 'release'
|
||||
|
||||
@@ -29,8 +29,11 @@ async function ensureLocalDir(dir) {
|
||||
// ── Transfer performance tuning ──────────────────────────────────────────────
|
||||
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
|
||||
// dramatically improving throughput over sequential stream piping.
|
||||
// Note: High concurrency (e.g. 64) can overwhelm SFTP servers, causing
|
||||
// extreme delays before the first chunk arrives. 8 balances throughput
|
||||
// on fast connections with responsiveness on slower servers.
|
||||
const TRANSFER_CHUNK_SIZE = 512 * 1024; // 512KB per SFTP request
|
||||
const TRANSFER_CONCURRENCY = 64; // 64 parallel SFTP requests
|
||||
const TRANSFER_CONCURRENCY = 8; // 8 parallel SFTP requests
|
||||
// Progress IPC throttle: sending too many IPC messages bogs down the event loop
|
||||
const PROGRESS_THROTTLE_MS = 100; // Send IPC at most every 100ms
|
||||
const PROGRESS_THROTTLE_BYTES = 256 * 1024; // Or every 256KB of progress
|
||||
|
||||
@@ -61,7 +61,7 @@ prepare() {
|
||||
|
||||
echo "[node-pty] rebuilding native modules for Electron on linux-${arch}"
|
||||
log_electron_runtime_info
|
||||
npx electron-rebuild
|
||||
npx electron-rebuild --arch "${arch}"
|
||||
|
||||
test -f "${release_dir}/pty.node"
|
||||
|
||||
|
||||
154
scripts/verify-linux-deb-artifact.sh
Executable file
154
scripts/verify-linux-deb-artifact.sh
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TEMP_DIR=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <amd64|arm64>" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
checksum() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$@"
|
||||
else
|
||||
shasum -a 256 "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "${cmd}" >/dev/null 2>&1 || {
|
||||
echo "[deb-verify] missing required command: ${cmd}" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
assert_exists() {
|
||||
local file="$1"
|
||||
if [[ ! -e "${file}" ]]; then
|
||||
echo "[deb-verify] expected file does not exist: ${file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_executable() {
|
||||
local file="$1"
|
||||
if [[ ! -x "${file}" ]]; then
|
||||
echo "[deb-verify] expected executable file is missing or not executable: ${file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
log_file_info() {
|
||||
local file="$1"
|
||||
echo "[deb-verify] file: ${file}"
|
||||
ls -lh "${file}"
|
||||
file "${file}"
|
||||
checksum "${file}"
|
||||
}
|
||||
|
||||
assert_file_arch() {
|
||||
local file="$1"
|
||||
local expected="$2"
|
||||
local info
|
||||
|
||||
info="$(file "${file}")"
|
||||
echo "[deb-verify] arch-check: ${info}"
|
||||
if [[ "${info}" != *"${expected}"* ]]; then
|
||||
echo "[deb-verify] unexpected architecture for ${file}" >&2
|
||||
echo "[deb-verify] expected substring: ${expected}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_loadable_native_module() {
|
||||
local electron_bin="$1"
|
||||
local native_module="$2"
|
||||
|
||||
echo "[deb-verify] loading native module with packaged Electron runtime: ${native_module}"
|
||||
ELECTRON_RUN_AS_NODE=1 "${electron_bin}" -e '
|
||||
const path = require("node:path");
|
||||
require(path.resolve(process.argv[1]));
|
||||
console.log("[deb-verify] native module loaded successfully");
|
||||
' "${native_module}"
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ $# -ne 1 ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
local deb_arch="$1"
|
||||
local prebuild_arch
|
||||
local expected_machine
|
||||
local deb_file
|
||||
local control_arch
|
||||
local electron_bin
|
||||
local main_binary
|
||||
local build_release_pty
|
||||
local prebuild_pty
|
||||
|
||||
require_cmd dpkg-deb
|
||||
require_cmd file
|
||||
|
||||
case "${deb_arch}" in
|
||||
amd64)
|
||||
prebuild_arch="x64"
|
||||
expected_machine="x86-64"
|
||||
;;
|
||||
arm64)
|
||||
prebuild_arch="arm64"
|
||||
expected_machine="ARM aarch64"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
|
||||
deb_file="$(find release -maxdepth 1 -type f -name "*-linux-${deb_arch}.deb" -print | sort | head -n 1)"
|
||||
if [[ -z "${deb_file}" ]]; then
|
||||
echo "[deb-verify] no deb artifact found for ${deb_arch} under release/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[deb-verify] verifying deb artifact: ${deb_file}"
|
||||
log_file_info "${deb_file}"
|
||||
|
||||
control_arch="$(dpkg-deb -f "${deb_file}" Architecture)"
|
||||
echo "[deb-verify] control architecture: ${control_arch}"
|
||||
if [[ "${control_arch}" != "${deb_arch}" ]]; then
|
||||
echo "[deb-verify] deb control architecture mismatch: expected ${deb_arch}, got ${control_arch}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${TEMP_DIR:-}"' EXIT
|
||||
dpkg-deb -x "${deb_file}" "${TEMP_DIR}"
|
||||
|
||||
electron_bin="${TEMP_DIR}/opt/Netcatty/netcatty"
|
||||
main_binary="${TEMP_DIR}/opt/Netcatty/netcatty"
|
||||
build_release_pty="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/node-pty/build/Release/pty.node"
|
||||
prebuild_pty="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-${prebuild_arch}/pty.node"
|
||||
|
||||
assert_executable "${electron_bin}"
|
||||
assert_exists "${build_release_pty}"
|
||||
assert_exists "${prebuild_pty}"
|
||||
|
||||
echo "[deb-verify] verifying packaged binary architectures"
|
||||
log_file_info "${main_binary}"
|
||||
log_file_info "${build_release_pty}"
|
||||
log_file_info "${prebuild_pty}"
|
||||
|
||||
assert_file_arch "${main_binary}" "${expected_machine}"
|
||||
assert_file_arch "${build_release_pty}" "${expected_machine}"
|
||||
assert_file_arch "${prebuild_pty}" "${expected_machine}"
|
||||
|
||||
assert_loadable_native_module "${electron_bin}" "${build_release_pty}"
|
||||
assert_loadable_native_module "${electron_bin}" "${prebuild_pty}"
|
||||
|
||||
echo "[deb-verify] deb artifact verification passed for ${deb_file}"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user