Skip to content

Commit 2fb186b

Browse files
authored
Merge pull request #38 from ZDOSS/codex/restore-browser-folder-controls
Restore browser project folder controls
2 parents 63b0bcc + 34c622d commit 2fb186b

12 files changed

Lines changed: 953 additions & 22 deletions

File tree

AI.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ The core domain in `packages/core/src/domain/` covers the entities the plan call
8686
- `AppShell` consumes key-scoped adapter `watch()` events for the active project and shows an explicit external-change banner with `Reload from storage` and `Keep my changes`; the Tauri Rust shell polls the active `.pms.json` fingerprint, reports edits/deletes/renames, and updates the expected revision before an intentional overwrite
8787
- the shared shell wraps routes in `ToastProvider`; route code should use `useToast()` for short-lived feedback and keep blocking or recoverable errors in `InlineAlert`
8888
- the shared shell exposes explicit project actions next to the save-state indicator: `Save now` / `Retry save` routes through the active `ProjectStoreAdapter.save()` plus `markSaving()` / `markSaved()` / `markSaveFailed()`, records a launcher recent from successful browser/folder save metadata, `Switch project` navigates to `/projects`, and `Close project` returns to the launcher immediately only for clean saved projects; dirty projects and unsaved in-memory imports/demos must confirm `Close without saving` before `closeProject()` runs
89+
- `StorageSettings` is the active-project storage-location surface, not a read-only trust report: a browser-local or unsaved web project can `Save to local folder`, while a folder-backed web project can `Change folder` or `Use browser storage`; each successful transition serializes the bundle with the intended trust, saves through the installed `window.__gph_store` adapter, calls `markSaved()` with returned metadata, and replaces the same-key launcher recent
90+
- storage-location transitions deliberately pass a null expected revision because the current revision belongs to the old target rather than the newly selected backend; folder selection checks `listFolderProjects()` and refuses to overwrite a same-ID `.pms.json` unless the adapter proves the selected directory is the same filesystem entry as the previous active binding through optional `isSelectedFolderSameAsPrevious()` semantics—display names and saved path strings are never folder identities. A confirmed re-selection of the current folder is a no-op rather than a blind rewrite, while collisions, scan errors, failed writes, and active-project switches call `restorePreviousFolder()` so an unsuccessful transition cannot leave future folder operations bound to the unaccepted directory
91+
- switching storage locations is copy-first and non-destructive: the old folder file or browser recovery copy remains in place, changing back to browser storage does not clear the shared browser folder handle needed by other folder-backed recents, and an `AbortError` from the native directory picker is a normal dismissal that does not mark the project save as failed
92+
- manual storage transitions serialize one target-trust snapshot before writing, then compare that snapshot with the latest active bundle normalized to the same target trust; the returned metadata still activates the new location, but a divergent latest bundle is immediately left dirty via `markUnsaved()` so edits made during the in-flight write are not falsely reported as saved and can auto-save next
93+
- web and desktop auto-save success and failure completions must verify that `latest.storageTrust` still matches the target they wrote before applying returned metadata or save-error state; this prevents an in-flight save to the previous location from reverting the active trust/path/revision or marking the newly active target as failed after a storage transition. Folder-to-folder moves are also disabled while the current folder project is dirty, because choosing a new browser folder handle changes the adapter target immediately.
8994
- the web shell now surfaces offline status and emits one lightweight due-item/reminder notification per project/day/count combination; local/self-hosted web builds capture `beforeinstallprompt` and show `Install app`, while hosted-demo builds suppress that install prompt and show a `Run locally` GitHub setup CTA instead
9095
- desktop storage now only writes to the filesystem when a folder path has actually been attached; otherwise the desktop shell behaves as browser-local storage on purpose instead of pretending to be folder-backed
9196
- every save now carries an explicit `browser` or `folder` target; an active browser folder handle or desktop folder path cannot silently promote a different browser-local project, and the PWA launcher exposes `Use browser storage` to clear its selected folder
@@ -709,6 +714,22 @@ The core domain in `packages/core/src/domain/` covers the entities the plan call
709714
- mounting a non-modal, keyboard-readable tutorial panel in `AppShell` with 13 steps, progress semantics, Back/Next/Exit controls, responsive bottom-sheet behavior, and real route transitions across Overview, Board, Backlog, Table, Roadmap, Calendar, Docs, Bug Triage, My Work, Search/commands, Trash, and Settings
710715
- synchronizing the wizard with manual sidebar navigation so users can skip ahead or revisit a feature without being forced back to the previous route
711716
- adding focused launcher/tutorial component coverage plus a full Playwright journey that advances through every tutorial stop and verifies the final handoff back to Overview
717+
- addressed the required Greptile storage-transition follow-up by:
718+
- requiring both the active `storageKey` and `storageTrust` to match the target captured by web and desktop auto-save before a rejected write can call `markSaveFailed()`
719+
- leaving the new target's `saveStatus`, `saveError`, dirty state, path, and external revision untouched when an older browser or folder write rejects after the user changes storage locations
720+
- adding rejection-path auto-save regressions for browser-to-folder web transitions and folder-to-browser desktop transitions, complementing the existing successful-completion race coverage
721+
- addressed the follow-up Greptile concurrent-edit finding by:
722+
- capturing the exact target-normalized JSON written by `StorageSettings.persistTo()` and comparing it with the latest active bundle after the adapter resolves
723+
- activating successful folder/browser metadata while retaining `isDirty: true` and `saveStatus: "idle"` when a project edit landed during the write, allowing the normal auto-save bridge to persist the newer state next
724+
- guarding successful and failed completion by the original project id and storage key so a late storage transition cannot mutate or show a save failure on a different project, and adding a focused Settings regression that proves the newer work item remains present but absent from the earlier saved snapshot
725+
- addressed the follow-up Greptile rejected-folder binding finding by:
726+
- extending `ProjectStoreAdapter` with optional `restorePreviousFolder()` rollback semantics for consumers that validate a directory after the native picker returns
727+
- checkpointing the prior web `FileSystemDirectoryHandle` before each accepted pick and restoring both the active in-memory handle and best-effort IndexedDB binding when Settings rejects a same-ID collision
728+
- invoking rollback before showing the collision warning and after any scan/save failure or active-project switch, with web-adapter coverage proving scans return to the accepted folder plus Settings coverage for collision, picker cancellation, and failed-write behavior
729+
- addressed the follow-up Greptile same-name folder identity finding by:
730+
- removing the `StorageSettings` path-suffix/display-name heuristic that could mistake two distinct directories with the same basename for one folder target
731+
- adding optional `ProjectStoreAdapter.isSelectedFolderSameAsPrevious()` semantics and implementing the web comparison with `FileSystemHandle.isSameEntry()`; unavailable or failed identity checks conservatively remain collisions
732+
- covering same-name distinct handle comparisons in the web adapter plus both Settings outcomes: distinct directories retain the collision warning and restore the accepted binding, while a confirmed re-selection of the current directory performs no write
712733

713734
## Open follow-on planning
714735

Readme.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ The durable project format is a `.pms.json` bundle under `.pm-suite/` when using
8282
Important behavior:
8383

8484
- New folder-backed projects write their initial `.pm-suite/<project-id>.pms.json` immediately.
85+
- An open browser-local project can move to a chosen folder from **Settings -> Storage** without being recreated; a folder-backed project can change folders or switch back to browser storage from the same panel.
86+
- Changing storage locations writes and activates the new copy first. The previous browser or folder copy is retained as a recovery point, and a collision or failed move restores the previously accepted folder binding instead of leaving later saves pointed at a rejected directory. Folder identity is checked by the browser's filesystem handles, so two different folders with the same name cannot bypass the same-project collision guard; reselecting the actual current folder is recognized without rewriting its file.
87+
- If an edit arrives while that location change is still writing, Grillo activates the new destination but keeps the newer edit marked unsaved so the next auto-save includes it.
88+
- Auto-save results belong to the storage target that started them. If the user changes targets while a save is in flight, its later success or failure cannot overwrite the new target's saved state.
8589
- Folder-backed browser saves keep a browser-local recovery copy so reloads are recoverable when the browser cannot restore folder access.
8690
- Reopening folder-backed recents asks for folder access and refuses to silently open stale browser recovery when the selected folder is missing the recorded project file.
8791
- Imported JSON and demo projects are unsaved in-memory sessions until the user explicitly saves them.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { act, cleanup, render } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { buildProjectFromTemplate, type StorageMetadata } from "@gph/core";
4+
import { useProjectStore } from "@gph/ui";
5+
import { useAutoSave } from "./auto-save";
6+
import { DesktopStorageAdapter } from "./storage/desktop-storage";
7+
8+
function AutoSaveHarness() {
9+
useAutoSave();
10+
return null;
11+
}
12+
13+
describe("desktop auto-save", () => {
14+
beforeEach(() => {
15+
vi.useFakeTimers();
16+
const original = buildProjectFromTemplate("software-project", "Auto-save target");
17+
const bundle = {
18+
...original,
19+
projectSettings: { ...original.projectSettings, storageTrust: "folder" as const }
20+
};
21+
useProjectStore.setState({
22+
bundle,
23+
storageKey: bundle.project.id,
24+
storagePath: `Client Work/.pm-suite/${bundle.project.id}.pms.json`,
25+
storageTrust: "folder",
26+
externalRevision: 101,
27+
isDirty: true,
28+
saveStatus: "idle",
29+
saveError: null
30+
});
31+
});
32+
33+
afterEach(() => {
34+
cleanup();
35+
vi.restoreAllMocks();
36+
vi.useRealTimers();
37+
});
38+
39+
it("ignores an in-flight failure after the active storage target changes", async () => {
40+
let rejectSave!: (reason: Error) => void;
41+
const pendingSave = new Promise<StorageMetadata>((_resolve, reject) => {
42+
rejectSave = reject;
43+
});
44+
const save = vi.spyOn(DesktopStorageAdapter.adapter, "save").mockReturnValue(pendingSave);
45+
vi.spyOn(console, "warn").mockImplementation(() => undefined);
46+
const projectId = useProjectStore.getState().bundle!.project.id;
47+
48+
render(<AutoSaveHarness />);
49+
50+
await act(async () => {
51+
vi.advanceTimersByTime(251);
52+
await Promise.resolve();
53+
});
54+
expect(save).toHaveBeenCalledOnce();
55+
56+
const current = useProjectStore.getState().bundle!;
57+
act(() => {
58+
useProjectStore.setState({
59+
bundle: {
60+
...current,
61+
projectSettings: { ...current.projectSettings, storageTrust: "browser" }
62+
},
63+
storagePath: null,
64+
storageTrust: "browser",
65+
externalRevision: 909,
66+
isDirty: false,
67+
saveStatus: "saved",
68+
saveError: null
69+
});
70+
});
71+
72+
await act(async () => {
73+
rejectSave(new Error("Old folder write failed"));
74+
await Promise.resolve();
75+
await Promise.resolve();
76+
});
77+
78+
expect(useProjectStore.getState()).toMatchObject({
79+
storagePath: null,
80+
storageTrust: "browser",
81+
externalRevision: 909,
82+
isDirty: false,
83+
saveStatus: "saved",
84+
saveError: null
85+
});
86+
});
87+
});

apps/desktop/src/platform/auto-save.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ export function useAutoSave() {
3232
);
3333
const latest = useProjectStore.getState();
3434
if (!latest.bundle || latest.storageKey !== key || latest.bundle.project.id !== projectId) return;
35+
if (latest.storageTrust !== targetTrust) return;
3536
if (latest.serialize() === json) {
3637
latest.markSaved(key, meta.displayPath, meta.trust, meta.externalRevision);
3738
} else {
3839
latest.markUnsaved(meta.externalRevision);
3940
}
4041
} catch (err) {
4142
const latest = useProjectStore.getState();
42-
if (latest.storageKey === key) {
43+
if (latest.storageKey === key && latest.storageTrust === targetTrust) {
4344
latest.markSaveFailed(err instanceof Error ? err.message : "Auto-save failed.");
4445
}
4546
console.warn("Auto-save failed:", err);
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { act, cleanup, render } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { buildProjectFromTemplate, type StorageMetadata } from "@gph/core";
4+
import { useProjectStore } from "@gph/ui";
5+
import { useAutoSave } from "./auto-save";
6+
import { WebStorageAdapter } from "./storage/web-storage";
7+
8+
function AutoSaveHarness() {
9+
useAutoSave();
10+
return null;
11+
}
12+
13+
describe("web auto-save", () => {
14+
beforeEach(() => {
15+
vi.useFakeTimers();
16+
const bundle = buildProjectFromTemplate("software-project", "Auto-save target");
17+
useProjectStore.setState({
18+
bundle,
19+
storageKey: bundle.project.id,
20+
storagePath: null,
21+
storageTrust: "browser",
22+
externalRevision: 101,
23+
isDirty: true,
24+
saveStatus: "idle",
25+
saveError: null
26+
});
27+
});
28+
29+
afterEach(() => {
30+
cleanup();
31+
vi.restoreAllMocks();
32+
vi.useRealTimers();
33+
});
34+
35+
it("ignores an in-flight result after the active storage target changes", async () => {
36+
let resolveSave!: (metadata: StorageMetadata) => void;
37+
const pendingSave = new Promise<StorageMetadata>((resolve) => {
38+
resolveSave = resolve;
39+
});
40+
const save = vi.spyOn(WebStorageAdapter.adapter, "save").mockReturnValue(pendingSave);
41+
const projectId = useProjectStore.getState().bundle!.project.id;
42+
43+
render(<AutoSaveHarness />);
44+
45+
await act(async () => {
46+
vi.advanceTimersByTime(251);
47+
await Promise.resolve();
48+
});
49+
expect(save).toHaveBeenCalledOnce();
50+
51+
const current = useProjectStore.getState().bundle!;
52+
act(() => {
53+
useProjectStore.setState({
54+
bundle: {
55+
...current,
56+
projectSettings: { ...current.projectSettings, storageTrust: "folder" }
57+
},
58+
storagePath: `Client Work/.pm-suite/${projectId}.pms.json`,
59+
storageTrust: "folder",
60+
externalRevision: 909,
61+
isDirty: false,
62+
saveStatus: "saved"
63+
});
64+
});
65+
66+
await act(async () => {
67+
resolveSave({
68+
key: projectId,
69+
displayPath: null,
70+
externalRevision: 202,
71+
trust: "browser"
72+
});
73+
await Promise.resolve();
74+
await Promise.resolve();
75+
});
76+
77+
expect(useProjectStore.getState()).toMatchObject({
78+
storagePath: `Client Work/.pm-suite/${projectId}.pms.json`,
79+
storageTrust: "folder",
80+
externalRevision: 909,
81+
isDirty: false,
82+
saveStatus: "saved"
83+
});
84+
});
85+
86+
it("ignores an in-flight failure after the active storage target changes", async () => {
87+
let rejectSave!: (reason: Error) => void;
88+
const pendingSave = new Promise<StorageMetadata>((_resolve, reject) => {
89+
rejectSave = reject;
90+
});
91+
const save = vi.spyOn(WebStorageAdapter.adapter, "save").mockReturnValue(pendingSave);
92+
vi.spyOn(console, "warn").mockImplementation(() => undefined);
93+
const projectId = useProjectStore.getState().bundle!.project.id;
94+
95+
render(<AutoSaveHarness />);
96+
97+
await act(async () => {
98+
vi.advanceTimersByTime(251);
99+
await Promise.resolve();
100+
});
101+
expect(save).toHaveBeenCalledOnce();
102+
103+
const current = useProjectStore.getState().bundle!;
104+
act(() => {
105+
useProjectStore.setState({
106+
bundle: {
107+
...current,
108+
projectSettings: { ...current.projectSettings, storageTrust: "folder" }
109+
},
110+
storagePath: `Client Work/.pm-suite/${projectId}.pms.json`,
111+
storageTrust: "folder",
112+
externalRevision: 909,
113+
isDirty: false,
114+
saveStatus: "saved",
115+
saveError: null
116+
});
117+
});
118+
119+
await act(async () => {
120+
rejectSave(new Error("Old browser write failed"));
121+
await Promise.resolve();
122+
await Promise.resolve();
123+
});
124+
125+
expect(useProjectStore.getState()).toMatchObject({
126+
storagePath: `Client Work/.pm-suite/${projectId}.pms.json`,
127+
storageTrust: "folder",
128+
externalRevision: 909,
129+
isDirty: false,
130+
saveStatus: "saved",
131+
saveError: null
132+
});
133+
});
134+
});

apps/web/src/platform/auto-save.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,15 @@ export function useAutoSave() {
3434
);
3535
const latest = useProjectStore.getState();
3636
if (!latest.bundle || latest.storageKey !== key || latest.bundle.project.id !== projectId) return;
37+
if (latest.storageTrust !== targetTrust) return;
3738
if (latest.serialize() === json) {
3839
latest.markSaved(key, meta.displayPath, meta.trust, meta.externalRevision);
3940
} else {
4041
latest.markUnsaved(meta.externalRevision);
4142
}
4243
} catch (err) {
4344
const latest = useProjectStore.getState();
44-
if (latest.storageKey === key) {
45+
if (latest.storageKey === key && latest.storageTrust === targetTrust) {
4546
latest.markSaveFailed(err instanceof Error ? err.message : "Auto-save failed.");
4647
}
4748
console.warn("Auto-save failed:", err);

0 commit comments

Comments
 (0)