Merge remote-tracking branch 'origin/main' into feat/decision-control-pr7

# Conflicts:
#	packages/core/guide-viewer-manifest.ts
This commit is contained in:
Michael Ramos
2026-09-02 13:22:10 -07:00
21 changed files with 199 additions and 14 deletions
@@ -44,6 +44,16 @@ const SKILL_MD_PATH = join(
"SKILL.md",
);
const ANNOTATE_SKILL_MD_PATH = join(
import.meta.dir,
"..",
"..",
"skills",
"core",
"plannotator-annotate",
"SKILL.md",
);
// CLI sources that parse flags. index.ts strips flags via
// args.indexOf/includes; the shared parsers use case/=== comparisons.
const PARSER_SOURCES = [
@@ -112,6 +122,7 @@ for (const m of indexSource.matchAll(/args\[0\] === "([a-z][a-z0-9-]*)"/g)) {
// --- The skill's documented surface ---
const skillDoc = readFileSync(SKILL_MD_PATH, "utf-8");
const annotateSkillDoc = readFileSync(ANNOTATE_SKILL_MD_PATH, "utf-8");
const documentedSubcommands = new Set<string>();
for (const fence of skillDoc.matchAll(/```[a-z]*\n([\s\S]*?)```/g)) {
@@ -126,6 +137,16 @@ for (const m of skillDoc.matchAll(/(?<![\w-])--[a-z][a-z0-9-]*/g)) {
}
describe("plannotator knowledge skill freshness", () => {
test("file approval guidance enables the annotate gate", () => {
expect(skillDoc).toContain("plannotator annotate <file> --gate --json");
expect(annotateSkillDoc).toContain(
"plannotator annotate <path-or-url> --gate --json",
);
expect(annotateSkillDoc).toContain(
"`--json` only changes the output format and does not enable approval by itself",
);
});
test("extractors actually extracted (a parsing regression must not pass vacuously)", () => {
// If the fence or flag regexes stop matching, the forward assertions
// below would pass on empty sets. Pin known-present anchors instead of
+10 -2
View File
@@ -8,19 +8,27 @@ disable-model-invocation: true
Use this skill when the user wants to annotate a document in Plannotator instead of reviewing it inline in chat.
Run:
Run for ordinary annotation/feedback:
```bash
plannotator annotate <path-or-url>
```
Run when the user asks to review, approve, accept, or gate a generated plan/spec/document:
```bash
plannotator annotate <path-or-url> --gate --json
```
Plain `annotate` has no **Approve** button; it only supports feedback or closing the session. Never promise an approval action unless `--gate` is present. `--json` only changes the output format and does not enable approval by itself.
Behavior:
1. Launch the command with Bash.
2. Wait for the browser review to finish.
3. If annotations are returned, address them directly.
4. If the session closes without feedback, say so briefly and continue.
5. An approval may still carry notes — a `"decision": "approved"` result with a
5. In a `--gate --json` session, an approval may still carry notes — a `"decision": "approved"` result with a
`"feedback"` field. Read those notes and carry them into subsequent work, but
do not revise the document over them: they are guidance, not a change request.
6. If the command reports that the arguments could not be resolved to a file,
+3
View File
@@ -14,6 +14,7 @@ This skill is the knowledge layer. The `plannotator-review`, `plannotator-annota
| The user wants | Run |
| --- | --- |
| Review a plan you produced | Nothing. Plan review opens automatically on plan exit via hooks. Never run bare `plannotator` yourself. |
| Review and explicitly approve a plan/spec saved as a file | `plannotator annotate <file> --gate --json` |
| Review current code changes | `plannotator review` |
| Review a GitHub PR or GitLab MR | `plannotator review <PR_URL>` |
| Annotate a markdown, text, config, or HTML file | `plannotator annotate <file>` |
@@ -58,6 +59,8 @@ plannotator annotate <target> [--markdown] [--no-jina] [--app | --static] [--ren
Opens one document, page, or app in the annotation UI and returns the human's annotations on stdout.
Plain `annotate` is feedback-only: it shows **Close** but no **Approve** button. When the user asks to review, approve, accept, or gate a generated plan/spec/document saved as a file, always add `--gate --json`. Do not tell the user they can approve a plain `annotate` session. If the plan is being handed off through the host agent's native plan flow, do not launch `annotate`; let the plan-exit hook open the approval UI automatically.
Targets:
- Markdown and text files: `.md`, `.mdx`, `.txt`.
+1 -1
View File
@@ -1,4 +1,4 @@
export type DefaultDiffType = 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all';
export type DefaultDiffType = 'since-base' | 'local-vs-remote' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all';
export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong';
/**
+2 -2
View File
@@ -5,9 +5,9 @@
import type { GuideViewerAssets } from "./guide-format";
export const GUIDE_VIEWER_MANIFEST: Omit<GuideViewerAssets, "baseUrl"> = {
js: "viewer.AnU5z-dd.js",
js: "viewer.blC1stpK.js",
css: "viewer.ByOFxnTX.css",
jsIntegrity: "sha384-iOm8MrJ1lGtn93UvbQr02EvCOHwQqXcDd84+5pUwfCSoYy146a+tLaemgZjNql8c",
jsIntegrity: "sha384-q9ezrElUnwFDt71ATj8EDsVTJ0L3yA6NnhGlxMFq2MaWrjLVYCiW97zd+kjaziXM",
cssIntegrity: "sha384-CbCmVLMKK2kuIPAIp7dRn/7IN9SfDOou7bzRmdtcyRqHNy/dqv+V83zzYY0gVBhE",
langs: {
"astro": "chunks/astro.BykyiR6i.js",
+3 -1
View File
@@ -1370,7 +1370,7 @@ const ReviewApp: React.FC = () => {
const lastColon = rest.lastIndexOf(':');
if (lastColon !== -1) {
const sub = rest.slice(lastColon + 1);
if (['since-base', 'uncommitted', 'staged', 'unstaged', 'last-commit', 'branch', 'merge-base', 'all'].includes(sub)) {
if (['since-base', 'local-vs-remote', 'uncommitted', 'staged', 'unstaged', 'last-commit', 'branch', 'merge-base', 'all'].includes(sub)) {
return { activeWorktreePath: rest.slice(0, lastColon), activeDiffBase: sub };
}
}
@@ -4161,6 +4161,7 @@ const ReviewApp: React.FC = () => {
: undefined;
const compactActionBusy = isSendingFeedback || isApproving || isExiting || isPlatformActioning;
const showsLocalVsRemoteEmptyState = activeDiffBase === 'local-vs-remote';
const compactReviewActions: CompactReviewAction[] = !isCompactTouchLayout
? []
: !origin
@@ -5058,6 +5059,7 @@ const ReviewApp: React.FC = () => {
<h3 className="text-sm font-medium text-foreground">No changes</h3>
<p className="text-xs text-muted-foreground mt-1">
{activeDiffBase === 'since-base' && `No changes since ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''} — committed, uncommitted, or untracked.`}
{showsLocalVsRemoteEmptyState && `Your local branch matches its remote-tracking branch${activeWorktreePath ? ' in this worktree' : ''}.`}
{activeDiffBase.startsWith('commit:') && 'This commit has no changes.'}
{activeDiffBase === 'uncommitted' && `No uncommitted changes${activeWorktreePath ? ' in this worktree' : ' to review'}.`}
{activeDiffBase === 'staged' && "No staged changes. Stage some files with git add."}
@@ -17,6 +17,7 @@ interface DiffTypePickerProps {
*/
const OPTION_HINTS: Record<string, string> = {
'since-base': "Everything since your branch split from the base — committed, uncommitted, and untracked. What a PR would show if you committed it all and pushed.",
'local-vs-remote': "Your local branch and working tree compared with its remote-tracking branch — committed, uncommitted, and untracked differences from the last fetch.",
uncommitted: "All your local changes — anything you haven't committed yet.",
staged: "Only what you've run `git add` on.",
unstaged: "What `git diff` shows with no arguments.",
+1 -1
View File
@@ -21,7 +21,7 @@ interface UseGitAddReturn {
stageError: string | null;
}
const STAGEABLE_DIFF_TYPES = new Set(['since-base', 'uncommitted', 'unstaged', 'workspace-current', 'workspace-unstaged']);
const STAGEABLE_DIFF_TYPES = new Set(['since-base', 'local-vs-remote', 'uncommitted', 'unstaged', 'workspace-current', 'workspace-unstaged']);
export function useGitAdd({ activeDiffBase, onFileViewed, sidecarStaged }: UseGitAddOptions): UseGitAddReturn {
// Session intent per path: true = staged this session, false = unstaged
@@ -65,6 +65,7 @@ function describeDiff(ctx: FeedbackDiffContext): string {
}
switch (mode) {
case "uncommitted": label = "Uncommitted changes"; break;
case "local-vs-remote": label = "Local vs remote branch (committed + uncommitted + untracked)"; break;
case "staged": label = "Staged changes"; break;
case "unstaged": label = "Unstaged changes"; break;
case "last-commit": label = "Last commit"; break;
@@ -47,6 +47,17 @@ describe('initializeReviewSetup', () => {
expect(store.get('defaultDiffType')).toBe('uncommitted');
});
test('an unseen reviewer inherits a local-vs-remote default', () => {
installMemoryBackend({
'plannotator-default-diff-type': 'local-vs-remote',
});
const store = makeStore();
expect(initializeReviewSetup(store)).toBe(true);
expect(store.get('reviewPanelView')).toBe('tree');
expect(store.get('defaultDiffType')).toBe('local-vs-remote');
});
test('an explicit persisted view survives a session that never tripped the seen gate', () => {
// Non-git / workspace / PR / no-since-base sessions never reach the
// initializer, so a reviewer can persist a view from Settings while
@@ -275,6 +275,14 @@ describe("buildAgentReviewUserMessage — Ask AI scenario coverage", () => {
});
describe("getLocalDiffInstruction", () => {
test("describes local-vs-remote as an upstream-to-working-tree comparison", () => {
const instruction = getLocalDiffInstruction("local-vs-remote");
expect(instruction?.target).toContain("remote-tracking branch");
expect(instruction?.inspect).toContain("@{upstream}");
expect(instruction?.inspect).toContain("untracked files");
});
test("returns null for non-local diff types", () => {
expect(getLocalDiffInstruction("p4-default")).toBeNull();
});
+5
View File
@@ -208,6 +208,11 @@ export function getLocalDiffInstruction(
}
switch (effectiveDiffType) {
case "local-vs-remote":
return {
target: "the local branch and working tree compared with its configured remote-tracking branch, including committed, uncommitted, and untracked differences",
inspect: "Resolve the current upstream with `git rev-parse --abbrev-ref --symbolic-full-name @{upstream}`, then run `git diff <upstream>` (no right-hand ref) and inspect untracked files from `git status --porcelain` separately.",
};
case "since-base": {
const base = defaultBranch || "main";
return {
+9
View File
@@ -26,12 +26,21 @@ import {
getServerConfig,
resolveGuideShareUrl,
resolveSharingEnabled,
resolveDefaultDiffType,
DEFAULT_GUIDE_SHARE_URL,
__setConfigLockTimingsForTest,
__setConfigSaveMergeWindowHookForTest,
} from "./config";
import type { PlannotatorConfig } from "./config";
describe("resolveDefaultDiffType", () => {
test("accepts local-vs-remote as a persisted review default", () => {
expect(resolveDefaultDiffType({
diffOptions: { defaultDiffType: "local-vs-remote" },
})).toBe("local-vs-remote");
});
});
describe("parseReviewAnalysisConfig", () => {
test("accepts independent boolean analysis flags", () => {
expect(parseReviewAnalysisConfig({ semanticDiff: false })).toEqual({ semanticDiff: false });
+1 -1
View File
@@ -605,7 +605,7 @@ export function isAgentTerminalSide(
export function resolveDefaultDiffType(cfg?: PlannotatorConfig): DefaultDiffType {
const v = cfg?.diffOptions?.defaultDiffType as string | undefined;
if (v === 'branch') return 'merge-base';
return v === 'since-base' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : 'since-base';
return v === 'since-base' || v === 'local-vs-remote' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : 'since-base';
}
/**
+37
View File
@@ -1282,6 +1282,42 @@ describe("review-core", () => {
expect(result.patch).toContain("diff --git a/untracked.txt b/untracked.txt");
});
test("local-vs-remote includes committed, dirty, and untracked changes since the tracked branch", async () => {
const repoDir = initRepo();
const remoteDir = makeTempDir("plannotator-review-core-upstream-");
git(remoteDir, ["init", "--bare", "--initial-branch=main"]);
git(repoDir, ["remote", "add", "origin", remoteDir]);
git(repoDir, ["push", "--set-upstream", "origin", "main"]);
writeFileSync(join(repoDir, "committed.txt"), "committed\n", "utf-8");
git(repoDir, ["add", "committed.txt"]);
git(repoDir, ["commit", "-m", "local commit"]);
writeFileSync(join(repoDir, "tracked.txt"), "dirty\n", "utf-8");
writeFileSync(join(repoDir, "untracked.txt"), "new\n", "utf-8");
const runtime = makeRuntime(repoDir);
const context = await getGitContext(runtime, repoDir);
const result = await runGitDiff(runtime, "local-vs-remote", context.defaultBranch, repoDir);
// Intentional copy pins: these labels are the product terminology shown in the diff picker and header.
expect(context.diffOptions).toContainEqual({
id: "local-vs-remote",
label: "Local vs remote branch",
});
expect(result.error).toBeUndefined();
expect(result.label).toBe("main: Local vs origin/main");
expect(result.patch).toContain("diff --git a/committed.txt b/committed.txt");
expect(result.patch).toContain("diff --git a/tracked.txt b/tracked.txt");
expect(result.patch).toContain("diff --git a/untracked.txt b/untracked.txt");
});
test("git context hides local-vs-remote when the current branch has no upstream", async () => {
const repoDir = initRepo();
const context = await getGitContext(makeRuntime(repoDir), repoDir);
expect(context.diffOptions.map((option) => option.id)).not.toContain("local-vs-remote");
});
test("since-base falls back to HEAD when the requested base cannot resolve", async () => {
const repoDir = initRepo("trunk");
const runtime = makeRuntime(repoDir);
@@ -1649,6 +1685,7 @@ describe("review-core", () => {
// which pointed git at a non-existent cwd and silently collapsed the diff mode.
const subTypes = [
"since-base",
"local-vs-remote",
"uncommitted",
"staged",
"unstaged",
+55
View File
@@ -27,6 +27,7 @@ const MAX_UNTRACKED_FINGERPRINT_CONTENT_BYTES = 1024 * 1024;
export type DiffType =
| "since-base"
| "local-vs-remote"
| "uncommitted"
| "staged"
| "unstaged"
@@ -658,6 +659,11 @@ export async function getGitContext(
}
}
const upstreamBranch = await getCurrentUpstreamBranch(runtime, cwd);
if (upstreamBranch) {
diffOptions.push({ id: "local-vs-remote", label: "Local vs remote branch" });
}
diffOptions.push(
{ id: "uncommitted", label: "Uncommitted changes" },
{ id: "staged", label: "Staged changes" },
@@ -1309,6 +1315,20 @@ export async function getWorkingTreeDiffFromBase(
return removeTrackedDeletions(trackedPatch, new Set(untracked.paths)) + untracked.diff;
}
/** Resolve the remote-tracking branch configured for the current local branch. */
export async function getCurrentUpstreamBranch(
runtime: ReviewGitRuntime,
cwd?: string,
): Promise<string | null> {
const result = await runtime.runGit(
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
{ cwd },
);
if (result.exitCode !== 0) return null;
const branch = result.stdout.trim();
return branch && branch !== "@{upstream}" ? branch : null;
}
/**
* Build the exact, applyable patch used to materialize immutable analysis snapshots.
*
@@ -1333,6 +1353,7 @@ export async function getGitSnapshotMaterializationPatch(
}
if (
effectiveDiffType !== "since-base"
&& effectiveDiffType !== "local-vs-remote"
&& effectiveDiffType !== "uncommitted"
&& effectiveDiffType !== "staged"
&& effectiveDiffType !== "unstaged"
@@ -1370,6 +1391,13 @@ export async function getGitSnapshotMaterializationPatch(
return removeTrackedDeletions(tracked, new Set(files.paths)) + files.diff;
}
if (effectiveDiffType === "local-vs-remote") {
const upstreamBranch = await getCurrentUpstreamBranch(runtime, cwd);
if (!upstreamBranch) throw new Error("The current branch does not have a remote tracking branch.");
const tracked = await binaryDiff([...common, "--end-of-options", upstreamBranch]);
return removeTrackedDeletions(tracked, new Set(files.paths)) + files.diff;
}
const mergeBaseResult = await runtime.runGit(
["merge-base", "--end-of-options", defaultBranch, "HEAD"],
{ cwd },
@@ -1422,6 +1450,7 @@ function assertGitSuccess(
// extract the pure parser to a browser-safe module.
const WORKTREE_SUB_TYPES = new Set([
"since-base",
"local-vs-remote",
"uncommitted",
"staged",
"unstaged",
@@ -1559,6 +1588,16 @@ export async function runGitDiff(
} else if (effectiveDiffType.startsWith("commit:")) {
return { patch: "", label: `Error: ${diffType}`, error: "Invalid commit ref" };
} else switch (effectiveDiffType) {
case "local-vs-remote": {
const upstreamBranch = await getCurrentUpstreamBranch(runtime, cwd);
if (!upstreamBranch) {
throw new Error("The current branch does not have a remote tracking branch.");
}
patch = await getWorkingTreeDiffFromBase(runtime, upstreamBranch, cwd, options);
label = `Local vs ${displayRef(upstreamBranch)}`;
break;
}
case "since-base": {
// The composite "GitHub view": merge-base(base, HEAD) vs the working
// tree (note: no right-hand ref on the diff), plus untracked files.
@@ -1946,6 +1985,15 @@ export async function getGitDiffFingerprint(
appendUntrackedFingerprint(runtime, runReadOnlyGit, parts, cwd);
switch (effectiveDiffType) {
case "local-vs-remote": {
const upstreamBranch = await getCurrentUpstreamBranch(runtime, cwd);
if (!upstreamBranch) return null;
const upstreamTip = await runReadOnlyGit(["rev-parse", "--end-of-options", upstreamBranch]);
parts.push(upstreamBranch, upstreamTip.exitCode === 0 ? upstreamTip.stdout.trim() : "no-upstream");
if (!(await hashDiffOutput(["--end-of-options", upstreamBranch]))) return null;
if (!(await hashUntracked())) return null;
break;
}
case "since-base": {
// Content hash of the mb→worktree diff catches edits; headSha (always
// in `parts`) catches commits that only re-partition the sections;
@@ -2060,6 +2108,13 @@ export async function getFileContentsForDiff(
}
switch (effectiveDiffType) {
case "local-vs-remote": {
const upstreamBranch = await getCurrentUpstreamBranch(runtime, cwd);
return {
oldContent: upstreamBranch ? await gitShow(upstreamBranch, oldFilePath) : null,
newContent: await readWorkingTree(filePath),
};
}
case "since-base": {
const mbResult = await runtime.runGit(["merge-base", "--end-of-options", defaultBranch, "HEAD"], { cwd });
// Degrade to HEAD (matching runGitDiff), not defaultBranch — when the base
+12 -2
View File
@@ -5,6 +5,7 @@ import {
type GitDiffOptions,
type ReviewGitRuntime,
detectRemoteDefaultBranch,
getCurrentUpstreamBranch,
getFileContentsForDiff as getGitFileContentsForDiff,
getGitContext,
getGitDiffFingerprint,
@@ -167,7 +168,7 @@ export interface PreparedLocalReviewDiff {
fingerprint?: string;
}
const GIT_DIFF_TYPES = new Set(["since-base", "uncommitted", "staged", "unstaged", "last-commit", "branch", "merge-base", "all"]);
const GIT_DIFF_TYPES = new Set(["since-base", "local-vs-remote", "uncommitted", "staged", "unstaged", "last-commit", "branch", "merge-base", "all"]);
const JJ_DIFF_TYPES = new Set(["jj-current", "jj-last", "jj-line", "jj-evolog", "jj-all"]);
function selectNearestProvider(
@@ -231,6 +232,7 @@ export function createGitProvider(runtime: ReviewGitRuntime): VcsProvider {
const effectiveDiffType = parseWorktreeDiffType(diffType)?.subType ?? diffType;
return (
effectiveDiffType === "since-base" ||
effectiveDiffType === "local-vs-remote" ||
effectiveDiffType === "uncommitted" ||
effectiveDiffType === "unstaged"
);
@@ -722,6 +724,7 @@ function supportsGitSnapshot(diffType: string): boolean {
const effective = parseWorktreeDiffType(diffType)?.subType ?? diffType;
return effective !== "all" && (
effective === "since-base"
|| effective === "local-vs-remote"
|| effective === "uncommitted"
|| effective === "staged"
|| effective === "unstaged"
@@ -778,6 +781,14 @@ async function materializeGitSnapshot(
const mergeBase = await git(runtime, cwd, ["merge-base", "--", options.base, "HEAD"]);
return createSyntheticSnapshot(runtime, cwd, mergeBase, [patch]);
}
if (diffType === "local-vs-remote") {
const upstream = await getCurrentUpstreamBranch(runtime, cwd);
if (!upstream) {
throw new Error("The current branch does not have a remote tracking branch.");
}
const from = await resolveCommit(runtime, cwd, upstream);
return createSyntheticSnapshot(runtime, cwd, from, [patch]);
}
const head = await resolveCommit(runtime, cwd, "HEAD");
if (diffType === "uncommitted" || diffType === "staged") {
return createSyntheticSnapshot(runtime, cwd, head, [patch]);
@@ -882,4 +893,3 @@ async function materializeJjSnapshot(
throw error;
}
}
+1
View File
@@ -157,6 +157,7 @@ const DEFAULT_DIFF_TYPE_OPTIONS = [
// "All Changes" belongs to since-base (the flagship composite); uncommitted
// reverts to its plain name so the two stay distinguishable side by side.
{ value: 'since-base' as const, label: 'All Changes (Recommended)', description: "Everything since your branch split from main — committed, uncommitted, and untracked" },
{ value: 'local-vs-remote' as const, label: 'Local vs Remote Branch', description: "Your local branch and working tree compared with its last-fetched remote-tracking branch" },
{ value: 'uncommitted' as const, label: 'Uncommitted', description: "Everything you've changed since your last commit" },
{ value: 'unstaged' as const, label: 'Unstaged', description: "Only changes you haven't staged yet" },
{ value: 'staged' as const, label: 'Staged', description: "Only changes you've staged for commit" },
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test';
import { resetStorageBackend, setStorageBackend } from '../utils/storage';
import { SETTINGS } from './settings';
import { ConfigStoreForTest } from './configStore';
import { setReviewPanelView } from './reviewView';
import { setReviewDefaultDiffType, setReviewPanelView } from './reviewView';
function installMemoryBackend(): Map<string, string> {
const values = new Map<string, string>();
@@ -74,4 +74,16 @@ describe('reviewPanelViewLastUsed setting', () => {
// ...but the user's last-used view survived.
expect(store.get('reviewPanelViewLastUsed')).toBe('tree');
});
test('local-vs-remote persists as a Tree-compatible default', () => {
const values = installMemoryBackend();
const store = makeStore();
setReviewPanelView('sections', undefined, store);
setReviewDefaultDiffType('local-vs-remote', store);
expect(store.get('defaultDiffType')).toBe('local-vs-remote');
expect(store.get('reviewPanelView')).toBe('tree');
expect(values.get('plannotator-default-diff-type')).toBe('local-vs-remote');
});
});
+1
View File
@@ -56,6 +56,7 @@ export function getPersistedReviewPanelView(): 'sections' | 'tree' | undefined {
export type ReviewDefaultDiffType =
| 'since-base'
| 'local-vs-remote'
| 'uncommitted'
| 'unstaged'
| 'staged'
+3 -3
View File
@@ -288,18 +288,18 @@ export const SETTINGS = {
},
defaultDiffType: {
defaultValue: 'since-base' as 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
defaultValue: 'since-base' as 'since-base' | 'local-vs-remote' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
fromCookie: () => {
const v = storage.getItem('plannotator-default-diff-type');
if (v === 'branch') return 'merge-base' as const;
return v === 'since-base' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
return v === 'since-base' || v === 'local-vs-remote' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
},
toCookie: (v: string) => storage.setItem('plannotator-default-diff-type', v),
serverKey: 'diffOptions',
fromServer: (sc: Record<string, unknown>) => {
const v = (sc.diffOptions as Record<string, unknown> | undefined)?.defaultDiffType;
if (v === 'branch') return 'merge-base' as const;
return v === 'since-base' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
return v === 'since-base' || v === 'local-vs-remote' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
},
toServer: (v: string) => ({ diffOptions: { defaultDiffType: v } }),
},