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
21 changes: 21 additions & 0 deletions AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ The core domain in `packages/core/src/domain/` covers the entities the plan call
- `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
- the shared shell wraps routes in `ToastProvider`; route code should use `useToast()` for short-lived feedback and keep blocking or recoverable errors in `InlineAlert`
- 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
- `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
- 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
- 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
- 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
- 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.
- 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
- 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
- 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
Expand Down Expand Up @@ -709,6 +714,22 @@ The core domain in `packages/core/src/domain/` covers the entities the plan call
- 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
- synchronizing the wizard with manual sidebar navigation so users can skip ahead or revisit a feature without being forced back to the previous route
- 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
- addressed the required Greptile storage-transition follow-up by:
- 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()`
- 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
- 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
- addressed the follow-up Greptile concurrent-edit finding by:
- capturing the exact target-normalized JSON written by `StorageSettings.persistTo()` and comparing it with the latest active bundle after the adapter resolves
- 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
- 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
- addressed the follow-up Greptile rejected-folder binding finding by:
- extending `ProjectStoreAdapter` with optional `restorePreviousFolder()` rollback semantics for consumers that validate a directory after the native picker returns
- 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
- 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
- addressed the follow-up Greptile same-name folder identity finding by:
- removing the `StorageSettings` path-suffix/display-name heuristic that could mistake two distinct directories with the same basename for one folder target
- adding optional `ProjectStoreAdapter.isSelectedFolderSameAsPrevious()` semantics and implementing the web comparison with `FileSystemHandle.isSameEntry()`; unavailable or failed identity checks conservatively remain collisions
- 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

## Open follow-on planning

Expand Down
4 changes: 4 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ The durable project format is a `.pms.json` bundle under `.pm-suite/` when using
Important behavior:

- New folder-backed projects write their initial `.pm-suite/<project-id>.pms.json` immediately.
- 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.
- 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.
- 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.
- 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.
- Folder-backed browser saves keep a browser-local recovery copy so reloads are recoverable when the browser cannot restore folder access.
- 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.
- Imported JSON and demo projects are unsaved in-memory sessions until the user explicitly saves them.
Expand Down
87 changes: 87 additions & 0 deletions apps/desktop/src/platform/auto-save.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildProjectFromTemplate, type StorageMetadata } from "@gph/core";
import { useProjectStore } from "@gph/ui";
import { useAutoSave } from "./auto-save";
import { DesktopStorageAdapter } from "./storage/desktop-storage";

function AutoSaveHarness() {
useAutoSave();
return null;
}

describe("desktop auto-save", () => {
beforeEach(() => {
vi.useFakeTimers();
const original = buildProjectFromTemplate("software-project", "Auto-save target");
const bundle = {
...original,
projectSettings: { ...original.projectSettings, storageTrust: "folder" as const }
};
useProjectStore.setState({
bundle,
storageKey: bundle.project.id,
storagePath: `Client Work/.pm-suite/${bundle.project.id}.pms.json`,
storageTrust: "folder",
externalRevision: 101,
isDirty: true,
saveStatus: "idle",
saveError: null
});
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
});

it("ignores an in-flight failure after the active storage target changes", async () => {
let rejectSave!: (reason: Error) => void;
const pendingSave = new Promise<StorageMetadata>((_resolve, reject) => {
rejectSave = reject;
});
const save = vi.spyOn(DesktopStorageAdapter.adapter, "save").mockReturnValue(pendingSave);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const projectId = useProjectStore.getState().bundle!.project.id;

render(<AutoSaveHarness />);

await act(async () => {
vi.advanceTimersByTime(251);
await Promise.resolve();
});
expect(save).toHaveBeenCalledOnce();

const current = useProjectStore.getState().bundle!;
act(() => {
useProjectStore.setState({
bundle: {
...current,
projectSettings: { ...current.projectSettings, storageTrust: "browser" }
},
storagePath: null,
storageTrust: "browser",
externalRevision: 909,
isDirty: false,
saveStatus: "saved",
saveError: null
});
});

await act(async () => {
rejectSave(new Error("Old folder write failed"));
await Promise.resolve();
await Promise.resolve();
});

expect(useProjectStore.getState()).toMatchObject({
storagePath: null,
storageTrust: "browser",
externalRevision: 909,
isDirty: false,
saveStatus: "saved",
saveError: null
});
});
});
3 changes: 2 additions & 1 deletion apps/desktop/src/platform/auto-save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ export function useAutoSave() {
);
const latest = useProjectStore.getState();
if (!latest.bundle || latest.storageKey !== key || latest.bundle.project.id !== projectId) return;
if (latest.storageTrust !== targetTrust) return;
if (latest.serialize() === json) {
latest.markSaved(key, meta.displayPath, meta.trust, meta.externalRevision);
} else {
latest.markUnsaved(meta.externalRevision);
}
} catch (err) {
const latest = useProjectStore.getState();
if (latest.storageKey === key) {
if (latest.storageKey === key && latest.storageTrust === targetTrust) {
latest.markSaveFailed(err instanceof Error ? err.message : "Auto-save failed.");
}
console.warn("Auto-save failed:", err);
Expand Down
134 changes: 134 additions & 0 deletions apps/web/src/platform/auto-save.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildProjectFromTemplate, type StorageMetadata } from "@gph/core";
import { useProjectStore } from "@gph/ui";
import { useAutoSave } from "./auto-save";
import { WebStorageAdapter } from "./storage/web-storage";

function AutoSaveHarness() {
useAutoSave();
return null;
}

describe("web auto-save", () => {
beforeEach(() => {
vi.useFakeTimers();
const bundle = buildProjectFromTemplate("software-project", "Auto-save target");
useProjectStore.setState({
bundle,
storageKey: bundle.project.id,
storagePath: null,
storageTrust: "browser",
externalRevision: 101,
isDirty: true,
saveStatus: "idle",
saveError: null
});
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
});

it("ignores an in-flight result after the active storage target changes", async () => {
let resolveSave!: (metadata: StorageMetadata) => void;
const pendingSave = new Promise<StorageMetadata>((resolve) => {
resolveSave = resolve;
});
const save = vi.spyOn(WebStorageAdapter.adapter, "save").mockReturnValue(pendingSave);
const projectId = useProjectStore.getState().bundle!.project.id;

render(<AutoSaveHarness />);

await act(async () => {
vi.advanceTimersByTime(251);
await Promise.resolve();
});
expect(save).toHaveBeenCalledOnce();

const current = useProjectStore.getState().bundle!;
act(() => {
useProjectStore.setState({
bundle: {
...current,
projectSettings: { ...current.projectSettings, storageTrust: "folder" }
},
storagePath: `Client Work/.pm-suite/${projectId}.pms.json`,
storageTrust: "folder",
externalRevision: 909,
isDirty: false,
saveStatus: "saved"
});
});

await act(async () => {
resolveSave({
key: projectId,
displayPath: null,
externalRevision: 202,
trust: "browser"
});
await Promise.resolve();
await Promise.resolve();
});

expect(useProjectStore.getState()).toMatchObject({
storagePath: `Client Work/.pm-suite/${projectId}.pms.json`,
storageTrust: "folder",
externalRevision: 909,
isDirty: false,
saveStatus: "saved"
});
});

it("ignores an in-flight failure after the active storage target changes", async () => {
let rejectSave!: (reason: Error) => void;
const pendingSave = new Promise<StorageMetadata>((_resolve, reject) => {
rejectSave = reject;
});
const save = vi.spyOn(WebStorageAdapter.adapter, "save").mockReturnValue(pendingSave);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const projectId = useProjectStore.getState().bundle!.project.id;

render(<AutoSaveHarness />);

await act(async () => {
vi.advanceTimersByTime(251);
await Promise.resolve();
});
expect(save).toHaveBeenCalledOnce();

const current = useProjectStore.getState().bundle!;
act(() => {
useProjectStore.setState({
bundle: {
...current,
projectSettings: { ...current.projectSettings, storageTrust: "folder" }
},
storagePath: `Client Work/.pm-suite/${projectId}.pms.json`,
storageTrust: "folder",
externalRevision: 909,
isDirty: false,
saveStatus: "saved",
saveError: null
});
});

await act(async () => {
rejectSave(new Error("Old browser write failed"));
await Promise.resolve();
await Promise.resolve();
});

expect(useProjectStore.getState()).toMatchObject({
storagePath: `Client Work/.pm-suite/${projectId}.pms.json`,
storageTrust: "folder",
externalRevision: 909,
isDirty: false,
saveStatus: "saved",
saveError: null
});
});
});
3 changes: 2 additions & 1 deletion apps/web/src/platform/auto-save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@ export function useAutoSave() {
);
const latest = useProjectStore.getState();
if (!latest.bundle || latest.storageKey !== key || latest.bundle.project.id !== projectId) return;
if (latest.storageTrust !== targetTrust) return;
if (latest.serialize() === json) {
latest.markSaved(key, meta.displayPath, meta.trust, meta.externalRevision);
} else {
latest.markUnsaved(meta.externalRevision);
}
} catch (err) {
const latest = useProjectStore.getState();
if (latest.storageKey === key) {
if (latest.storageKey === key && latest.storageTrust === targetTrust) {
latest.markSaveFailed(err instanceof Error ? err.message : "Auto-save failed.");
}
console.warn("Auto-save failed:", err);
Expand Down
Loading
Loading