Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: "--model opus"
direct_prompt: |
prompt: |
Fix the audit issue described in this GitHub issue.

1. Read issue #$ISSUE_NUMBER body with `gh issue view $ISSUE_NUMBER`
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

111 changes: 107 additions & 4 deletions src/hooks/useAutoSave.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ vi.mock("@/utils/reentryGuard", () => ({

vi.mock("@/utils/debug", () => ({
autoSaveLog: vi.fn(),
saveError: vi.fn(),
}));

import { useAutoSave } from "./useAutoSave";
Expand All @@ -62,7 +63,6 @@ describe("useAutoSave", () => {
});

vi.mocked(useTabStore.getState).mockReturnValue({
activeTabId: { main: "tab-1" },
tabs: { main: [{ id: "tab-1" }] },
} as unknown as ReturnType<typeof useTabStore.getState>);

Expand Down Expand Up @@ -197,7 +197,6 @@ describe("useAutoSave", () => {

it("skips when no tabs exist", async () => {
vi.mocked(useTabStore.getState).mockReturnValue({
activeTabId: { main: null },
tabs: { main: [] },
} as unknown as ReturnType<typeof useTabStore.getState>);

Expand Down Expand Up @@ -310,7 +309,6 @@ describe("useAutoSave", () => {

it("saves multiple dirty tabs in the same window", async () => {
vi.mocked(useTabStore.getState).mockReturnValue({
activeTabId: { main: "tab-1" },
tabs: { main: [{ id: "tab-1" }, { id: "tab-2" }] },
} as unknown as ReturnType<typeof useTabStore.getState>);

Expand Down Expand Up @@ -341,9 +339,114 @@ describe("useAutoSave", () => {
expect(saveToPath).toHaveBeenCalledWith("tab-2", "/tmp/doc2.md", "Content 2", "auto");
});

it("prevents overlapping save cycles (reentry guard)", async () => {
// Make saveToPath take a long time (longer than the interval)
let resolveSave: (() => void) | null = null;
vi.mocked(saveToPath).mockImplementation(
() => new Promise<boolean>((resolve) => {
resolveSave = () => resolve(true);
})
);

renderHook(() => useAutoSave());

// First interval fires — starts saving
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(saveToPath).toHaveBeenCalledTimes(1);

// Second interval fires while first is still pending
await act(async () => {
vi.advanceTimersByTime(1000);
});
// Should still be 1 — second call was blocked by reentry guard
expect(saveToPath).toHaveBeenCalledTimes(1);

// Complete the first save
await act(async () => {
resolveSave?.();
});

// Advance past debounce (5s) so next save can fire
await act(async () => {
vi.advanceTimersByTime(5000);
});
// Now a new save should have started
expect(saveToPath).toHaveBeenCalledTimes(2);
});

it("re-reads document state per tab (not stale snapshot)", async () => {
let callCount = 0;
const getDocMock = vi.fn(() => {
callCount++;
// Simulate content changing between calls (first tab save modifies state)
return {
isDirty: true,
filePath: "/tmp/doc.md",
content: `Content v${callCount}`,
isMissing: false,
isDivergent: false,
};
});

vi.mocked(useDocumentStore.getState).mockReturnValue({
getDocument: getDocMock,
} as unknown as ReturnType<typeof useDocumentStore.getState>);

vi.mocked(useTabStore.getState).mockReturnValue({
tabs: { main: [{ id: "tab-1" }, { id: "tab-2" }] },
} as unknown as ReturnType<typeof useTabStore.getState>);

renderHook(() => useAutoSave());

await act(async () => {
vi.advanceTimersByTime(1000);
});

// getDocument should be called once per tab (2 calls total)
expect(getDocMock).toHaveBeenCalledTimes(2);
// Each save should get fresh content
expect(saveToPath).toHaveBeenCalledWith("tab-1", "/tmp/doc.md", "Content v1", "auto");
expect(saveToPath).toHaveBeenCalledWith("tab-2", "/tmp/doc.md", "Content v2", "auto");
});

it("continues saving remaining tabs when one tab throws", async () => {
vi.mocked(useTabStore.getState).mockReturnValue({
tabs: { main: [{ id: "tab-1" }, { id: "tab-2" }] },
} as unknown as ReturnType<typeof useTabStore.getState>);

const getDocMock = vi.fn((tabId: string) => ({
isDirty: true,
filePath: tabId === "tab-1" ? "/tmp/bad.md" : "/tmp/good.md",
content: "Content",
isMissing: false,
isDivergent: false,
}));

vi.mocked(useDocumentStore.getState).mockReturnValue({
getDocument: getDocMock,
} as unknown as ReturnType<typeof useDocumentStore.getState>);

// First tab throws, second succeeds
vi.mocked(saveToPath)
.mockRejectedValueOnce(new Error("disk full"))
.mockResolvedValueOnce(true);

renderHook(() => useAutoSave());

await act(async () => {
vi.advanceTimersByTime(1000);
});

// Both tabs should have been attempted
expect(saveToPath).toHaveBeenCalledTimes(2);
expect(saveToPath).toHaveBeenCalledWith("tab-1", "/tmp/bad.md", "Content", "auto");
expect(saveToPath).toHaveBeenCalledWith("tab-2", "/tmp/good.md", "Content", "auto");
});

it("handles window with no tabs entry (undefined)", async () => {
vi.mocked(useTabStore.getState).mockReturnValue({
activeTabId: { main: null },
tabs: {},
} as unknown as ReturnType<typeof useTabStore.getState>);

Expand Down
60 changes: 40 additions & 20 deletions src/hooks/useAutoSave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* - Checks isOperationInProgress() to avoid conflicting with manual save
* - Interval restarts when autoSaveInterval setting changes
* - Skips save if document is currently in the middle of an operation
* - Reentry guard prevents overlapping save cycles on slow filesystems
* - Re-reads doc state per tab (not a snapshot) so content is fresh before each save
*
* @coordinates-with saveToPath.ts — shared save logic with line ending handling
* @coordinates-with reentryGuard.ts — prevents concurrent save operations
Expand All @@ -28,20 +30,30 @@ import { useTabStore } from "@/stores/tabStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { saveToPath } from "@/utils/saveToPath";
import { isOperationInProgress } from "@/utils/reentryGuard";
import { autoSaveLog } from "@/utils/debug";
import { autoSaveLog, saveError } from "@/utils/debug";

const MIN_INTERVAL_MS = 1000;

export function useAutoSave() {
const windowLabel = useWindowLabel();
const autoSaveEnabled = useSettingsStore((s) => s.general.autoSaveEnabled);
const autoSaveInterval = useSettingsStore((s) => s.general.autoSaveInterval);
const lastSaveRef = useRef<number>(0);
const isSavingRef = useRef(false);

useEffect(() => {
if (!autoSaveEnabled) return;

const intervalMs = autoSaveInterval * 1000;
// Clamp interval to a safe minimum
const intervalMs = Math.max(
Number.isFinite(autoSaveInterval) ? autoSaveInterval * 1000 : MIN_INTERVAL_MS,
MIN_INTERVAL_MS
);

const checkAndSave = async () => {
// Reentry guard: prevent overlapping save cycles on slow filesystems
if (isSavingRef.current) return;

// Skip if manual save is in progress (prevents race condition)
if (isOperationInProgress(windowLabel, "save")) {
autoSaveLog("Skipping - manual save in progress");
Expand All @@ -50,29 +62,37 @@ export function useAutoSave() {

// Debounce: Prevent saves within 5 seconds of each other.
const DEBOUNCE_MS = 5000;
const now = Date.now();
if (now - lastSaveRef.current < DEBOUNCE_MS) return;
if (Date.now() - lastSaveRef.current < DEBOUNCE_MS) return;

// Iterate ALL tabs for this window — not just the active one
const tabs = useTabStore.getState().tabs[windowLabel] ?? [];
const documentStore = useDocumentStore.getState();
let anySaved = false;
isSavingRef.current = true;
try {
// Iterate ALL tabs for this window — not just the active one
const tabs = useTabStore.getState().tabs[windowLabel] ?? [];
let anySaved = false;

for (const tab of tabs) {
const doc = documentStore.getDocument(tab.id);
// Skip if no document, not dirty, no file path (untitled), file was deleted,
// or user chose "keep my changes" after external change (divergent)
if (!doc || !doc.isDirty || !doc.filePath || doc.isMissing || doc.isDivergent) continue;
for (const tab of tabs) {
// Re-read doc state per tab to get fresh content (avoids stale snapshot)
const doc = useDocumentStore.getState().getDocument(tab.id);
// Skip if no document, not dirty, no file path (untitled), file was deleted,
// or user chose "keep my changes" after external change (divergent)
if (!doc || !doc.isDirty || !doc.filePath || doc.isMissing || doc.isDivergent) continue;

const success = await saveToPath(tab.id, doc.filePath, doc.content, "auto");
if (success) {
anySaved = true;
autoSaveLog("Saved:", doc.filePath);
try {
const success = await saveToPath(tab.id, doc.filePath, doc.content, "auto");
if (success) {
anySaved = true;
autoSaveLog("Saved:", doc.filePath);
}
} catch (error) {
saveError("Auto-save failed for", doc.filePath, error);
}
}
}

if (anySaved) {
lastSaveRef.current = now;
if (anySaved) {
lastSaveRef.current = Date.now();
}
} finally {
isSavingRef.current = false;
}
};

Expand Down
18 changes: 15 additions & 3 deletions src/plugins/autoPair/__tests__/handlersExtra.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import {
handleTabJump,
handleClosingBracket,
handleBackspacePair,
createKeyHandler,
type AutoPairConfig,
} from "../handlers";
import { createKeyHandler } from "../keyHandler";

/* ------------------------------------------------------------------ */
/* Minimal schema & helpers */
Expand Down Expand Up @@ -354,9 +354,21 @@ describe("createKeyHandler", () => {
expect(event.preventDefault).toHaveBeenCalled();
});

it("does not handle Shift+Tab", () => {
it("handles Shift+Tab to jump before opening bracket", () => {
const handler = createKeyHandler(() => ENABLED);
const state = createState("()", 1);
const state = createState("()", 1); // cursor after (
const view = createMockView(state);
const event = createKeyEvent("Tab", { shiftKey: true });

const result = handler(view, event);
expect(result).toBe(true);
expect(event.preventDefault).toHaveBeenCalled();
expect(getCursorOffset(view.state)).toBe(0);
});

it("returns false for Shift+Tab when not after opening bracket", () => {
const handler = createKeyHandler(() => ENABLED);
const state = createState("hello", 3); // cursor in plain text
const view = createMockView(state);
const event = createKeyEvent("Tab", { shiftKey: true });

Expand Down
Loading