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
34 changes: 34 additions & 0 deletions electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,24 @@ function requestCloseFileFromApplicationMenu() {
window.webContents.send("workspace:closeFile");
}

function requestNewCheckpointFromApplicationMenu() {
if (!canOpenWorkspaceWindows()) {
return;
}
const window = getWorkspaceWindowForFileAction();
if (!window || window.webContents.isDestroyed()) {
return;
}
if (!isHeadless) {
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
window.webContents.send("workspace:newCheckpoint");
}

async function toggleTrackChangesFromApplicationMenu(trackChanges) {
const window = getWorkspaceWindowForFileAction();
if (!window || window.isDestroyed() || !getWorkspace(window)) {
Expand Down Expand Up @@ -1867,6 +1885,14 @@ function buildFileMenu() {
requestCloseFileFromApplicationMenu();
},
},
{
id: "new-checkpoint",
label: "New Checkpoint",
enabled: Boolean(getWorkspaceWindowForFileAction()),
click: () => {
requestNewCheckpointFromApplicationMenu();
},
},
{
id: "track-changes",
label: "Track Changes",
Expand Down Expand Up @@ -1918,6 +1944,14 @@ function updateDockMenu() {
requestNewFileFromApplicationMenu();
},
},
{
id: "dock-new-checkpoint",
label: "New Checkpoint",
enabled: Boolean(getWorkspaceWindowForFileAction()),
click: () => {
requestNewCheckpointFromApplicationMenu();
},
},
{
id: "dock-track-changes",
label: "Track Changes",
Expand Down
7 changes: 7 additions & 0 deletions electron/preload.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ const workspace = {
ipcRenderer.off("workspace:closeFile", wrapped);
};
},
onNewCheckpoint: (listener) => {
const wrapped = () => listener();
ipcRenderer.on("workspace:newCheckpoint", wrapped);
return () => {
ipcRenderer.off("workspace:newCheckpoint", wrapped);
};
},
open: (payload) => ipcRenderer.invoke("workspace:open", payload),
openInNewWindow: (payload) =>
ipcRenderer.invoke("workspace:openInNewWindow", payload),
Expand Down
2 changes: 2 additions & 0 deletions electron/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ export type DesktopWorkspaceApi = {
onNewFile(listener: () => void): () => void;
/** Fired when the native menu asks the workspace UI to close the active file. */
onCloseFile(listener: () => void): () => void;
/** Fired when the native menu asks the workspace UI to save a checkpoint. */
onNewCheckpoint(listener: () => void): () => void;
/**
* Opens a workspace. With a path (e.g. from a dropped folder) it adopts it
* directly; without one it shows the native directory picker. Resolves to
Expand Down
10 changes: 9 additions & 1 deletion src/extensions/files/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -886,12 +886,20 @@ function usePendingExternalWriteReviewPaths(
lix: Lix,
nodes: readonly FilesystemTreeNode[],
): ReadonlySet<string> {
const activeBranch = useQueryTakeFirst<{ value: string }>((lix) =>
qb(lix)
.selectFrom("lix_key_value")
.where("key", "=", "lix_workspace_branch_id")
.select(["value"]),
);
const activeBranchId =
typeof activeBranch?.value === "string" ? activeBranch.value : "";
const rangeRow = useQueryTakeFirst<{ value: unknown }>((lix) =>
qb(lix)
.selectFrom("lix_key_value_by_branch")
.select("value")
.where("key", "=", AGENT_TURN_COMMIT_RANGE_KEY)
.where("lixcol_branch_id", "=", "global")
.where("lixcol_branch_id", "=", activeBranchId)
.limit(1),
);
const ranges = useMemo(
Expand Down
8 changes: 8 additions & 0 deletions src/hooks/key-value/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type KeyDef<V> = {
defaultValue?: V | null;
};

export const FLASHTYPE_CHECKPOINTS_KEY = "flashtype_checkpoints" as const;

// Flashtype keys + per-key defaults
export const KEY_VALUE_DEFINITIONS = {
// Cross-branch UI state, not change-controlled
Expand All @@ -28,6 +30,12 @@ export const KEY_VALUE_DEFINITIONS = {
defaultValue: DEFAULT_FLASHTYPE_UI_STATE,
} as KeyDef<FlashtypeUiState>,

[FLASHTYPE_CHECKPOINTS_KEY]: {
defaultBranchId: "active",
untracked: true,
defaultValue: [],
} as KeyDef<readonly string[]>,

// Test-only keys used in unit tests to exercise tracked behavior
flashtype_test_tracked: {
defaultBranchId: "active",
Expand Down
9 changes: 5 additions & 4 deletions src/shell/agent-turn-review-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { qb } from "@/lib/lix-kysely";
export const AGENT_TURN_COMMIT_RANGE_KEY =
"flashtype_agent_turn_commit_range" as const;

const GLOBAL_BRANCH_ID = "global";
const agentTurnCommitRangeMutationQueues = new WeakMap<Lix, Promise<void>>();

export type AgentTurnCommitRange = {
Expand Down Expand Up @@ -33,11 +32,12 @@ export function agentTurnReviewId(
export async function readAgentTurnCommitRanges(
lix: Lix,
): Promise<readonly AgentTurnCommitRange[]> {
const branchId = await lix.activeBranchId();
const row = await qb(lix)
.selectFrom("lix_key_value_by_branch")
.select("value")
.where("key", "=", AGENT_TURN_COMMIT_RANGE_KEY)
.where("lixcol_branch_id", "=", GLOBAL_BRANCH_ID)
.where("lixcol_branch_id", "=", branchId)
.limit(1)
.executeTakeFirst();
return isAgentTurnCommitRangeStore(row?.value) ? row.value.ranges : [];
Expand All @@ -61,13 +61,14 @@ async function writeAgentTurnCommitRanges(
ranges: readonly AgentTurnCommitRange[],
): Promise<void> {
const value = serializeAgentTurnCommitRangeStore({ ranges });
const branchId = await lix.activeBranchId();
await qb(lix)
.insertInto("lix_key_value_by_branch")
.values({
key: AGENT_TURN_COMMIT_RANGE_KEY,
value,
lixcol_branch_id: GLOBAL_BRANCH_ID,
lixcol_global: true,
lixcol_branch_id: branchId,
lixcol_global: branchId === "global",
lixcol_untracked: true,
})
.onConflict((oc) =>
Expand Down
88 changes: 88 additions & 0 deletions src/shell/checkpoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { FLASHTYPE_CHECKPOINTS_KEY } from "@/hooks/key-value/schema";
import { qb } from "@/lib/lix-kysely";
import type { Lix } from "@/lib/lix-types";

const checkpointMutationQueues = new WeakMap<Lix, Promise<void>>();

export async function readCheckpointCommitIds(
lix: Lix,
): Promise<readonly string[]> {
const branchId = await lix.activeBranchId();
const row = await qb(lix)
.selectFrom("lix_key_value_by_branch")
.select("value")
.where("key", "=", FLASHTYPE_CHECKPOINTS_KEY)
.where("lixcol_branch_id", "=", branchId)
.limit(1)
.executeTakeFirst();
return isCheckpointCommitIdArray(row?.value) ? row.value : [];
}

export async function appendCheckpointCommitId(
lix: Lix,
commitId: string,
): Promise<void> {
if (commitId.length === 0) {
return;
}
await runCheckpointMutation(lix, async () => {
const commitIds = await readCheckpointCommitIds(lix);
await writeCheckpointCommitIds(lix, [...commitIds, commitId]);
});
}

async function writeCheckpointCommitIds(
lix: Lix,
commitIds: readonly string[],
): Promise<void> {
const value = commitIds.filter(
(commitId) => typeof commitId === "string" && commitId.length > 0,
);
const branchId = await lix.activeBranchId();
await qb(lix)
.insertInto("lix_key_value_by_branch")
.values({
key: FLASHTYPE_CHECKPOINTS_KEY,
value,
lixcol_branch_id: branchId,
lixcol_global: branchId === "global",
lixcol_untracked: true,
})
.onConflict((oc) =>
oc.columns(["key", "lixcol_branch_id"]).doUpdateSet({ value }),
)
.execute();
}

async function runCheckpointMutation<T>(
lix: Lix,
operation: () => Promise<T>,
): Promise<T> {
const previous = checkpointMutationQueues.get(lix) ?? Promise.resolve();
let releaseCurrent: (() => void) | undefined;
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
const next = previous.catch(() => undefined).then(() => current);
checkpointMutationQueues.set(lix, next);
await previous.catch(() => undefined);
try {
return await operation();
} finally {
releaseCurrent?.();
if (checkpointMutationQueues.get(lix) === next) {
checkpointMutationQueues.delete(lix);
}
}
}

function isCheckpointCommitIdArray(
value: unknown,
): value is readonly string[] {
return (
Array.isArray(value) &&
value.every(
(commitId) => typeof commitId === "string" && commitId.length > 0,
)
);
}
38 changes: 38 additions & 0 deletions src/shell/external-write-review-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,44 @@ describe("getExternalWriteReview", () => {
}
});

test("stores agent turn ranges on the active branch", async () => {
const lix = await openLix();
try {
const mainBranchId = await lix.activeBranchId();
const draftBranch = await lix.createBranch({ name: "Draft" });
const mainRange = agentRange({
id: "range-main",
beforeCommitId: "commit-main-before",
afterCommitId: "commit-main-after",
});
const draftRange = agentRange({
id: "range-draft",
beforeCommitId: "commit-draft-before",
afterCommitId: "commit-draft-after",
});

await appendAgentTurnCommitRange(lix, mainRange);
expect(
(await readAgentTurnCommitRanges(lix)).map((range) => range.id),
).toEqual(["range-main"]);

await lix.switchBranch({ branchId: draftBranch.id });
expect(await readAgentTurnCommitRanges(lix)).toEqual([]);

await appendAgentTurnCommitRange(lix, draftRange);
expect(
(await readAgentTurnCommitRanges(lix)).map((range) => range.id),
).toEqual(["range-draft"]);

await lix.switchBranch({ branchId: mainBranchId });
expect(
(await readAgentTurnCommitRanges(lix)).map((range) => range.id),
).toEqual(["range-main"]);
} finally {
await lix.close();
}
});

test("persists cleared files in the agent turn range", async () => {
const lix = await openLix();
try {
Expand Down
10 changes: 9 additions & 1 deletion src/shell/external-write-review-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,20 @@ export function useExternalWriteReview(args: {
readonly path?: string | null;
}): ExternalWriteReview | null {
const lix = useLix();
const activeBranch = useQueryTakeFirst<{ value: string }>((lix) =>
qb(lix)
.selectFrom("lix_key_value")
.where("key", "=", "lix_workspace_branch_id")
.select(["value"]),
);
const activeBranchId =
typeof activeBranch?.value === "string" ? activeBranch.value : "";
const rangeRow = useQueryTakeFirst<{ value: unknown }>((lix) =>
qb(lix)
.selectFrom("lix_key_value_by_branch")
.select("value")
.where("key", "=", AGENT_TURN_COMMIT_RANGE_KEY)
.where("lixcol_branch_id", "=", "global")
.where("lixcol_branch_id", "=", activeBranchId)
.limit(1),
);
const ranges = useMemo(
Expand Down
Loading
Loading