Compare commits

...

3 Commits

Author SHA1 Message Date
陈大猫
77fd7a42a8 fix(sftp): drag-upload goes to wrong directory after navigation (#311)
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): update currentPath immediately on navigation to prevent stale upload target

When navigating directories without a cache hit, currentPath was only
updated after the async file listing completed. If a drag-and-drop upload
occurred during or shortly after this window, getActivePane would return
the old currentPath, causing files to upload to the previous directory.

Now currentPath is updated immediately when loading begins, ensuring
upload operations always target the correct directory.

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

* fix(sftp): revert currentPath to previous value when navigation fails

Address review feedback: if the directory listing throws a non-session
error, restore currentPath to its previous value so later operations
(e.g. uploads) don't target a path that was never successfully loaded.

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

* fix(sftp): clear files when entering loading state to prevent stale interactions

Address P1 review: the loading overlay is pointer-events-none, so users
could still interact with old files during navigation. Since currentPath
is now updated immediately, actions like delete/rename would resolve
against the new path but display old files. Clear files and selection
when loading begins to eliminate this inconsistency.

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

* fix(sftp): restore previous files when reverting path on navigation error

Address P2 review: since files are now cleared when loading begins,
a failed navigation would leave the pane with the old path but an
empty file list. Save and restore the previous files alongside the
previous path in the error handler.

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

* fix(sftp): restore selected files when reverting on navigation error

Address P2 review: save and restore selectedFiles alongside path and
files in the error handler so users don't lose their selection when
a navigation attempt fails.

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

* fix(sftp): restore tab state when navigation is superseded by another request

Address P1 review: navSeqRef is tracked per-side not per-tab, so a
navigation from a different tab on the same side can invalidate this
request. When the sequence check causes an early return, restore this
tab's previous path, files, and selection instead of leaving it with
cleared files and a stale loading state.

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

* fix(sftp): avoid overwriting newer navigation state when superseded

When a navigation request is superseded by a newer one on the same tab
(e.g., fast A→B→C), the completing request should not blindly restore
its previous state, as that would overwrite the latest navigation's
optimistic update. Now we check if the tab's current path still matches
what this request set before restoring.

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

* fix(sftp): use per-tab request ID to guard superseded navigation restores

Replace the ambiguous currentPath equality check with a per-tab
navigation request ID (tabNavSeqRef). The old check failed when
refresh() triggered a navigation to the same path — the stale request
would incorrectly match and restore previous state.

The new approach tracks the latest requestId per tab, so:
- Same-tab superseded navigations (including same-path refreshes)
  correctly skip the restore.
- Cross-tab superseded navigations (different tab on the same side)
  correctly restore the orphaned tab's state.

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

* fix(sftp): track per-tab nav sequence to prevent cache-hit state overwrite

When a cache-miss request (A) is pending and a cache-hit request (B) runs
on the same tab, A's superseded handler could overwrite B's result because
it only checked path equality. Add tabNavSeqRef to track the latest
requestId per tab, so superseded requests correctly skip restore when
a newer navigation (including cache hits) has already handled the tab.

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

* fix: remove leftover merge conflict markers

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

* fix(sftp): restore to last confirmed state instead of optimistic state

When multiple navigations are in flight (A→B→C), the second navigation
would snapshot the optimistic state (path=B, files=[]) as its "previous"
state. If it then failed or was superseded, it would restore to an empty
file list instead of the last successfully loaded directory.

Introduce lastConfirmedRef to track the last known-good state per tab,
updated only on successful navigation (cache hit or listing success).
Restore-on-error and restore-on-supersede now always revert to this
confirmed state.

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

* fix(sftp): guard restores against stale connection after reconnect/disconnect

connect() and disconnect() reuse the same tab ID but bump navSeqRef
without updating tabNavSeqRef, so a pending navigation could restore
stale state from a previous host into a freshly reconnected tab.

Fix by:
- Capturing connectionId at navigation start and checking it in every
  updateTab restore callback (prev.connection?.id !== connectionId)
- Storing connectionId in lastConfirmedRef and re-seeding confirmed
  state when the connection changes, preventing old host data from
  being used as the restore target

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

* fix(sftp): keep files visible during loading and re-seed confirmed state

Two UI regressions fixed:

1. After a file mutation (delete/create/rename), lastConfirmedRef still
   held the pre-mutation snapshot. If the subsequent refresh failed, the
   error handler would restore stale files (e.g. resurrecting deleted
   items). Fix: re-seed confirmed state from the pane whenever it is
   settled (not loading), capturing any optimistic mutation updates.

2. Clearing files to [] on navigation start left a tab blank when
   superseded by another tab navigating on the same side. Fix: keep
   existing files visible during loading — the loading overlay already
   has pointer-events-none to prevent interaction. Files are replaced
   on success or restored from lastConfirmedRef on error/supersede.

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

* fix(sftp): block interaction with stale files during directory loading

The loading overlay used pointer-events-none, allowing clicks to pass
through to stale file rows underneath. Since currentPath is updated
immediately on navigation, interacting with old filenames during a slow
load would resolve paths against the new directory.

Remove pointer-events-none from the loading overlay so it properly
blocks all interaction with the stale file list while loading.

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

* chore: ignore .claude/ directory in eslint config

The .claude/worktrees/ directory contains full repo copies from agent
worktrees. ESLint was scanning these, causing 621 pre-existing errors
(no-undef for Node.js globals in .cjs files) that blocked npm run dev.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:05:41 +08:00
陈大猫
981c5de90d Merge pull request #310 from binaricat/fix/windows-auto-update-signing
fix: prevent macOS signing credentials from leaking to Windows builds
2026-03-11 11:40:58 +08:00
bincxz
0097d65a6e fix: prevent macOS signing credentials from leaking to Windows builds
Only pass CSC_LINK, CSC_KEY_PASSWORD, and Apple notarization secrets
to the macOS matrix job. Previously these were passed to all matrix
jobs, causing electron-builder to sign Windows .exe with the Apple
Developer ID certificate. Windows doesn't trust Apple's certificate
chain, so electron-updater's signature verification failed during
auto-update.

Closes #309
2026-03-11 11:15:04 +08:00
4 changed files with 122 additions and 19 deletions

View File

@@ -59,12 +59,12 @@ jobs:
- name: Build package
env:
ELECTRON_BUILDER_PUBLISH: "never"
# macOS code signing & notarization (ignored on other platforms)
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# macOS code signing & notarization (only for macOS builds)
CSC_LINK: ${{ matrix.name == 'macos' && secrets.MAC_CSC_LINK || '' }}
CSC_KEY_PASSWORD: ${{ matrix.name == 'macos' && secrets.MAC_CSC_KEY_PASSWORD || '' }}
APPLE_ID: ${{ matrix.name == 'macos' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.name == 'macos' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
- name: Upload artifacts

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
@@ -68,6 +68,18 @@ export const useSftpPaneActions = ({
isSessionError,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
// Track the latest navigation request ID per tab, so we can distinguish
// whether a superseded request was superseded by the same tab or a different tab.
const tabNavSeqRef = useRef(new Map<string, number>());
// Track the last confirmed (successfully loaded) state per tab, so that
// restore-on-error/supersede always reverts to a known-good state rather
// than an intermediate optimistic state from another in-flight navigation.
// Includes connectionId so stale entries from a previous host are ignored.
const lastConfirmedRef = useRef(
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string> }>(),
);
const navigateTo = useCallback(
async (
side: "left" | "right",
@@ -92,8 +104,9 @@ export const useSftpPaneActions = ({
return;
}
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
const cached = options?.force
? undefined
: dirCacheRef.current.get(cacheKey);
@@ -104,6 +117,13 @@ export const useSftpPaneActions = ({
cached.files
) {
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
tabNavSeqRef.current.set(activeTabId, requestId);
lastConfirmedRef.current.set(activeTabId, {
connectionId,
path,
files: cached.files,
selectedFiles: new Set(),
});
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
@@ -118,7 +138,36 @@ export const useSftpPaneActions = ({
}
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
// Re-seed confirmed state whenever the pane is settled (not loading), or
// when the connection has changed. This captures post-mutation state from
// optimistic updates (e.g. deleteFilesAtPath) so that a failed refresh
// doesn't resurrect deleted items.
const existing = lastConfirmedRef.current.get(activeTabId);
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
lastConfirmedRef.current.set(activeTabId, {
connectionId,
path: pane.connection.currentPath,
files: pane.files,
selectedFiles: pane.selectedFiles,
});
}
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
const previousPath = confirmed.path;
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
tabNavSeqRef.current.set(activeTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
// that gets superseded by another tab navigating on the same side.
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
selectedFiles: new Set(),
loading: true,
error: null,
}));
try {
let files: SftpFileEntry[];
@@ -164,13 +213,42 @@ export const useSftpPaneActions = ({
}
}
if (navSeqRef.current[side] !== requestId) return;
if (navSeqRef.current[side] !== requestId) {
// Another navigation on this side superseded this request.
// Only restore if no newer navigation has occurred on this specific tab
// AND the tab still belongs to the same connection (connect/disconnect
// bump navSeqRef but not tabNavSeqRef).
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
// Tab was reconnected or disconnected; don't restore stale state.
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
loading: false,
};
});
return;
}
dirCacheRef.current.set(cacheKey, {
files,
timestamp: Date.now(),
});
lastConfirmedRef.current.set(activeTabId, {
connectionId,
path,
files,
selectedFiles: new Set(),
});
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
@@ -181,13 +259,38 @@ export const useSftpPaneActions = ({
selectedFiles: new Set(),
}));
} catch (err) {
if (navSeqRef.current[side] !== requestId) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
}));
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
loading: false,
};
});
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
};
});
}
},
[

View File

@@ -411,7 +411,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
{/* Loading overlay - covers entire pane when navigating directories */}
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}

View File

@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
export default [
js.configs.recommended,
{
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**"],
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**"],
},
{
files: ["**/*.{ts,tsx}"],