Compare commits

...
Author SHA1 Message Date
github-actions[bot] b36dbc10ec Update Nix release metadata for v1.1.81 2026-08-19 02:48:08 +00:00
陈大猫 81dce5a3e7 Merge pull request #3059 from binaricat/fix/app-lock-window-drag 2026-08-19 10:24:15 +08:00
bincxz 4e794d4af1 fix(app-lock): keep window drag region on lock overlay
The lock screen is a full-window overlay, so it covers the title-bar
drag region and is the only chrome on a locked first paint. Mark the
backdrop as app-drag and keep unlock controls app-no-drag.

Closes #3058
2026-08-19 10:18:28 +08:00
陈大猫 1205ef37ae Merge pull request #3056 from binaricat/codex/fix-grok-reasoning-effort
fix(ai): support Grok Build reasoning effort
2026-08-19 09:39:48 +08:00
陈大猫 f7d4cc14c2 Merge pull request #3055 from binaricat/codex/fix-sftp-bookmark-popover-close
fix(sftp): close bookmark popover after path selection
2026-08-19 09:39:23 +08:00
bincxz 1617ef8956 fix(sftp): close bookmarks after path selection 2026-08-18 23:49:17 +08:00
7 changed files with 231 additions and 21 deletions

No files matched your search

@@ -824,3 +824,34 @@ test("AppLockOverlay keeps password fallback after system unlock failure", async
dom.cleanup();
}
});
test("AppLockOverlay keeps a window drag region while lock controls stay interactive", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(AppLockOverlay, {
locked: true,
reason: "manual",
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
onResetAppLock: async () => {},
}),
),
);
await flushEffects();
const overlay = dom.document.querySelector("[data-app-lock-overlay]");
const form = overlay?.querySelector("form");
assert.ok(overlay);
assert.ok(form);
assert.match(overlay.getAttribute("class") ?? "", /\bapp-drag\b/);
assert.match(form.getAttribute("class") ?? "", /\bapp-no-drag\b/);
} finally {
await renderer.unmount();
dom.cleanup();
}
});
+13
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
@@ -10,3 +11,15 @@ test("getAppLockErrorMessageKey maps unlock errors to localized message keys", (
assert.equal(getAppLockErrorMessageKey("incorrect"), "appLock.error.incorrectPassword");
assert.equal(getAppLockErrorMessageKey(null), null);
});
test("AppLockOverlay marks the backdrop as a window drag region", () => {
const source = readFileSync(new URL("./AppLockOverlay.tsx", import.meta.url), "utf8");
assert.match(
source,
/className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground app-drag"/,
);
assert.match(
source,
/className="flex w-full max-w-\[360px\] flex-col items-center gap-5 app-no-drag"/,
);
});
+4 -2
View File
@@ -339,9 +339,11 @@ export const AppLockOverlay: React.FC<AppLockOverlayProps> = ({
};
return (
// Overlay covers the title bar, and is the only chrome on a locked first
// paint, so the backdrop owns the window drag region.
<div
ref={overlayRootRef}
className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground"
className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground app-drag"
style={{ zIndex: 2147483647 }}
role="dialog"
data-state="open"
@@ -350,7 +352,7 @@ export const AppLockOverlay: React.FC<AppLockOverlayProps> = ({
aria-labelledby="app-lock-title"
>
<form
className="flex w-full max-w-[360px] flex-col items-center gap-5"
className="flex w-full max-w-[360px] flex-col items-center gap-5 app-no-drag"
onSubmit={handleSubmit}
>
<button
@@ -0,0 +1,157 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
test("selecting an inline SFTP bookmark closes its popover", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
installGlobal("window", window);
installGlobal("document", window.document);
installGlobal("navigator", window.navigator);
installGlobal("HTMLElement", window.HTMLElement);
installGlobal("HTMLInputElement", window.HTMLInputElement);
installGlobal("HTMLTextAreaElement", window.HTMLTextAreaElement);
installGlobal("Element", window.Element);
installGlobal("SVGElement", window.SVGElement);
installGlobal("Node", window.Node);
installGlobal("NodeFilter", window.NodeFilter);
installGlobal("MutationObserver", window.MutationObserver);
installGlobal("CustomEvent", window.CustomEvent);
installGlobal("Event", window.Event);
installGlobal("StorageEvent", window.StorageEvent);
installGlobal("localStorage", window.localStorage);
installGlobal("sessionStorage", window.sessionStorage);
installGlobal("getComputedStyle", window.getComputedStyle.bind(window));
installGlobal("requestAnimationFrame", window.requestAnimationFrame.bind(window));
installGlobal("cancelAnimationFrame", window.cancelAnimationFrame.bind(window));
installGlobal("ResizeObserver", ResizeObserverStub);
installGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const { default: React, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { SftpPaneToolbar } = await import("./SftpPaneToolbar.tsx");
const rootNode = window.document.getElementById("root");
assert.ok(rootNode);
const root = createRoot(rootNode);
const navigatedPaths: string[] = [];
try {
await act(async () => {
root.render(
React.createElement(SftpPaneToolbar, {
t: (key) => ({
"sftp.bookmark.list": "Bookmarked paths",
"sftp.bookmark.remove": "Remove bookmark",
"sftp.viewMode.switchToTree": "Switch to tree view",
}[key] ?? key),
pane: {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/home/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
},
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: (path) => navigatedPaths.push(path),
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
);
});
const trigger = window.document.querySelector<HTMLButtonElement>(
'button[aria-label="Bookmarked paths"]',
);
assert.ok(trigger);
await act(async () => trigger.click());
const bookmark = Array.from(window.document.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Web root"),
);
assert.ok(bookmark, "bookmark path should be visible after opening the popover");
await act(async () => bookmark.click());
assert.deepEqual(navigatedPaths, ["/srv/www"]);
assert.equal(
Array.from(window.document.querySelectorAll("button")).some((button) =>
button.textContent?.includes("Web root"),
),
false,
"bookmark popover should close after selecting a path",
);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
+13 -8
View File
@@ -19,6 +19,7 @@ import {
SftpPaneToolbar,
} from "./SftpPaneToolbar.tsx";
import type { SftpPane } from "../../application/state/sftp/types.ts";
import { Popover } from "../ui/popover.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
const toolbarSource = fs.readFileSync(
@@ -495,14 +496,18 @@ test("bookmark list renders saved paths as selectable rows", () => {
React.createElement(
TooltipProvider,
null,
React.createElement(SftpBookmarkList, {
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
t: (key: string) => ({
"sftp.bookmark.remove": "Remove bookmark",
}[key] ?? key),
}),
React.createElement(
Popover,
null,
React.createElement(SftpBookmarkList, {
bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }],
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
t: (key: string) => ({
"sftp.bookmark.remove": "Remove bookmark",
}[key] ?? key),
}),
),
),
);
+10 -8
View File
@@ -251,14 +251,16 @@ export const SftpBookmarkList: React.FC<SftpBookmarkListProps> = ({
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
<PopoverClose asChild>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
</PopoverClose>
</TooltipTrigger>
<TooltipContent>{bm.path}</TooltipContent>
</Tooltip>
+3 -3
View File
@@ -1,14 +1,14 @@
{
version = "1.1.80";
version = "1.1.81";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "sha256-2mwUopRx3XDLpXfePTT/MHfR30Sj2rFAzRy7Hn78C6c=";
hash = "sha256-WkHgVhYfmqM24Zk37tAAa3uqUPtvh7jfN2nV3SQ+ong=";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "sha256-a3uBC+6B2bnCG9US4smD9tTA7hISlsn8NnNBdWspRc4=";
hash = "sha256-TMRFLvD0hW7rLtdg4ktMoV7hLv+4vHkucCTnvv61axQ=";
};
};
}