Skip to content

feat(Git Diff): Discard individual entities and write them as Git Sync exports [5/5] - #10619

Open
yaoweiprc wants to merge 1 commit into
visual-diff-4-stagefrom
visual-diff-5-discard
Open

yaoweiprc wants to merge 1 commit into
visual-diff-4-stagefrom
visual-diff-5-discard

Conversation

@yaoweiprc

Copy link
Copy Markdown
Contributor

Overview

Adds a Visual Diff view to the git staging/commit modal, as a "Visual (Beta)" toggle alongside the existing raw text diff (Text / Visual switch above the diff panel).

The problem: a git-tracked file in Insomnia isn't one entity — it's a whole workspace export bundled into a single YAML blob (a collection's requests and folders, an environment's base + sub-environments, etc.). The existing diff view is a raw side-by-side text diff of that entire blob, which makes it hard to tell what actually changed when many entities live in one file, and forces staging/committing at the granularity of "the whole file" even when the user only cares about one request.

How it works:

  • Entity-level diffing — instead of diffing the file as one text blob, the two sides of the diff are parsed and walked as a tree, matching up requests, folders, environments, sub-environments, mock routes, etc. by their internal id. This turns one file-level diff into a list of per-entity changes (added / removed / modified), each with its own field-level breakdown. Top-level parts of the file that aren't entity collections — the workspace identity (type / schema version / name / meta), mock server settings, API spec, unit tests and CA certificates — are each treated as one whole entity, so every top-level key of the file is covered.
  • Purpose-built cards per entity — every entity type has a card whose tabs mirror its editor. Each tab is only shown if that section actually changed and is marked with a colored dot for added/removed/modified; cards collapse to a compact summary of colored chips (clicking a chip jumps into that tab); and every card has a diff-only Settings catch-all so no changed field is ever hidden — including a change a tab can't show as a row of its own (e.g. editing one of two same-named query params, or only reordering headers).
    • Requests: Params, Body, Auth, Headers, Scripts, Docs — with method, name and URL on one header line and the old values struck through on a second line when they changed.
    • Folders: Auth, Headers, Scripts, Environment, Docs. Nested requests/folders never show up here — they have their own cards.
    • WebSocket: Params, Auth, Headers, Docs · Socket.IO: Params, Events, Auth, Headers, Docs · gRPC: Method, Message, Headers, Reflection, Docs.
    • MCP: Auth, Headers, Env, Roots, with the transport and server URL/command in the header · Mock routes: Body, Headers, with method, path and status in the header.
    • Cookie jar: cookie-by-cookie changes, matched by name + domain + path.
    • Environments: Variables, Secrets and Settings — variable-by-variable changes, with vault-backed secrets always masked rather than diffed in the clear.
    • File-level sections (workspace identity, mock server, API spec, unit tests, CA certificates) use the generic card: the section type as a badge in the header and a single Fields tab.
    • Long values changed on both sides (an API spec, test suites, a request body, scripts, docs…) are shown as a compact read-only Monaco diff with changed words highlighted and unchanged regions folded, instead of two whole blocks. The editors are only created as they scroll into view, so a file with dozens of them still opens quickly.
  • Per-entity stage / unstage / discard — every card has a Stage (or Unstage, when viewing already-staged changes) button, so a user can commit just one request's change out of a file without pulling in every other unrelated change in that same file. This works by writing a synthetic blob straight into git's index (base content with only that one entity's node grafted in) rather than the usual whole-file git add, using a low-level isomorphic-git primitive that lets the index diverge from the on-disk working file. Before it's written, the grafted file is replayed through Git Sync's own import → export in a scratch in-memory database (the helper from fix: Warn when a Git Sync change cannot be discarded [INS-3830] #10585), so what lands in the index or the working tree is exactly what Insomnia itself would export: no leftover empty arrays, no ordering-only differences, and a collection that gains unit tests is written as a design document. Unstaged cards also have a click-to-confirm Discard, which rewrites only that entity in the working-tree file and syncs it back into the database; discarding a folder that only exists in the working tree warns that its contents go with it. Failures of any of these show an error toast.

What the per-entity actions guarantee:

  • An action only touches its own entity: a folder carries only its own fields (not its children), a base environment keeps its sub-environments, and fields missing on the target side are removed rather than silently kept.
  • Moved entities — and entities whose ancestor folder moved — are relocated, never duplicated.
  • No per-entity actions when a side has content that can't be parsed (e.g. merge-conflict markers; the view says it can't parse the file instead and links to the Text view), and no per-entity discard when the working-tree file was deleted (the file-level discard restores it whole).
  • The workspace identity only gets actions when the file exists on both sides; staging a new file's first entity carries its identity along.
  • All cards stay locked until the diff refreshes after an action, so back-to-back clicks can't compute from a stale diff.
  • Staging the last entity of a deleted file stages the deletion, and unstaging the last entity of a new file takes the file out of the index — no file with only the workspace identity is left behind.
  • When the replay isn't possible (not an importable Insomnia file, or the export fails, e.g. for a file without a base environment) or would add an entity the content doesn't have (the import fills in placeholders, e.g. for a removed mock server), the grafted content is written as is, keeping the working tree's top-level key order.

Folder cards: what each action does, including to the folder's contents. A folder card covers only the folder's own fields (name, Auth, Headers, Scripts, Environment, Docs); the requests and subfolders inside it have their own cards.

Folder is… Stage Unstage Discard
Added Stages an empty folder (its own fields only). Its contents are not staged; they stay in Unstaged as separate cards. Takes the folder out of the index, together with any of its contents already staged, without a prompt.¹ Deletes the folder from the working tree and the database with everything inside it, after a confirmation that says so.¹
Removed Removes the folder from the index with all its contents there, like deleting the folder in git; Staged shows a Removed card for the folder and for each item. Restores an empty folder in the index. Its contents stay staged as removed, each with its own Removed card in Staged, and are unstaged one by one.² Restores an empty folder in the working tree. Its contents stay removed, each with its own Removed card in Unstaged, and are restored one by one.²
Modified Stages only the folder's own field changes. Its contents stay as they are in the index; their own changes remain in Unstaged. Reverts only the folder's own fields to HEAD. Its contents in the index are untouched. Reverts only the folder's own fields to the index/HEAD. Its contents in the working tree are kept, including newly added requests.

¹ A request moved into a new folder from elsewhere goes with the folder. After unstaging the folder, Staged shows that request as Removed; unstaging it puts it back where HEAD has it. After discarding the folder, Unstaged shows it as Removed; discarding that brings it back to its original place. So it isn't lost, it just takes one more click. Only requests created inside the new folder are lost, which is what the confirmation warns about.

² Stage and Unstage/Discard aren't symmetric for a removed folder: staging the removal takes the contents with it, but unstaging or discarding it brings back only the folder. A folder carries only its own fields and keeps whatever contents the target side already has, which here is none. This lets each item be restored on its own; to restore everything at once, use the file-level Unstage or Discard.

The cards are readable in both dark and light themes: text on tinted backgrounds uses the theme's regular text color.

Text view: now also shows indentation-only changes, which Monaco hid by default — in YAML these are structural (e.g. a request moved out of a folder).

This is intentionally shipped as a "Visual (Beta)" toggle next to the existing Text view, not a replacement — the raw diff remains the source of truth for anyone who wants to see exactly what git will commit. When a file has changes but none of them can be shown as a card, the Visual view says so and links straight to the Text view.

Testing

  • Unit tests for the diff engine, the splice/staging logic (folders, moves, environments, file-level sections, key order, unparseable input) and every card's tab builder; the card tests also render each tab, so a render-time crash fails the test.
  • Performance of the in-card diffs checked against a stress file (30 requests with changed bodies, scripts and docs, plus a ~3000-line spec): with lazy creation the file opens faster than with the previous plain-text blocks, and scrolling stays smooth.
  • Manually verified end-to-end against a real git-synced project for every entity type: cards in added and modified states, plus stage, unstage and discard — checking the git index, the working-tree file and the app database after each action. This included a file with both staged and unstaged entity changes, folders with nested requests, moves, a merge-conflicted file, a deleted file and brand-new files. A scripted checker, independent of the diff engine, compared HEAD, the index and the working tree by entity id after every action; the bugs it found (e.g. folders that were empty when committed, same-named params, leftovers after per-entity actions, empty file shells) are fixed.

Known limitations

  • A pure move or reorder (an entity changes parent or position but none of its fields) produces no card; the Text view shows it.
  • File-level sections are staged/discarded as a unit, and a long value such as an API spec or the test suites is shown as one highlighted diff rather than broken down into a list of changed fields.
  • CA certificates: the app's v5 import/export doesn't read or write certificates, so in practice git-synced files don't contain them.
  • The Settings catch-all shows a changed sub-object (e.g. a request's settings) as a diff of its JSON rather than per field.
  • Per-entity actions rewrite the file the way Insomnia exports it, so fields unknown to this version (e.g. written by a newer Insomnia) are dropped from the file — the same as when Insomnia itself re-exports it.
  • Hand-edited YAML is out of scope for this version. A file someone edited by hand (comments, custom formatting, hand-written or duplicate ids, missing sections…) has unpredictable content: per-entity actions drop comments and formatting, and such content may not be diffed or spliced as expected. Use the Text view and the file-level actions for these files.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

✅ KSAI Review: Finished

  1. 09:54 UTC Tracing diff and import handling across related source files
  2. 09:56 UTC Tracing schema validation and git sync changes across modules
  3. 09:59 UTC Tracing call paths and reading related source files

Run report (federated) · 1 paid run · $0.2130 total
# Engine Result Model Turns Cost
1 opencode success glm-5.3-flash/max 73 $0.2126

Reviewing cost $0.2126; deciding how to run it cost $0.0004

@yaoweiprc
yaoweiprc added this pull request to stack #10620 October 16, 2026 09:56

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check Result
Scope Per-entity discard for git-sync's visual diff: IPC channel + route, main action with watcher re-import, VCS workdir write, canonicalize guard on stage/discard, folder-discard warning.
Mandate typescript-code-reviewer
Findings 2 Medium, 1 Low
Findings audit kreview:findings-auditor red-teamed 2 findings: 1 upheld, 1 reworded (discard-path field loss happens in the flush regardless), 1 new Medium promoted for the stage path.

The discard flow mirrors the stage flow and is wired end to end. The splice engine keeps siblings and shells missing parents, so per-entity discards converge with the index. The gap is canonicalization: the replay rewrites entities the action never touched.

Additional Risk pass: no secrets, no new attack surface beyond the existing stage-entity IPC pattern, no leaks or unbounded growth. One caveat: on the discard path the watcher flush strips unknown fields even when canonicalization is skipped, so the canonical-file.ts guard fixes the stage path only.

Comment on lines +66 to +69
const canonicalContent = await getCanonicalInsomniaFileContent(content);
// The import fills in what a file is missing, eg. a `__MOCK_SERVER_ID__` server for a mock file
// whose server was spliced out. Writing that would add an entity nobody added, so keep `content`.
if (canonicalContent === null || hasEntityIdsNotIn(canonicalContent, content)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium risk

Stage/discard canonicalize the spliced file through import→export. InsomniaFileSchema is a plain z.object, so the replay strips unknown fields. hasEntityIdsNotIn only catches entities the replay adds, never data it drops. Discarding req_1 deletes req_2's hand-added field with no warning; the banner covers committing only. On the discard path importAllFiles plus the DB→FS flush drops the field regardless, so this guard fixes the stage path; the discard path needs a UI warning. Import checkStrictSchemaMatch from ~/common/zod/check-schema-mismatch and InsomniaFileSchema from ~/common/import-v5-parser.

Suggested change
const canonicalContent = await getCanonicalInsomniaFileContent(content);
// The import fills in what a file is missing, eg. a `__MOCK_SERVER_ID__` server for a mock file
// whose server was spliced out. Writing that would add an entity nobody added, so keep `content`.
if (canonicalContent === null || hasEntityIdsNotIn(canonicalContent, content)) {
// A file with fields this version does not know loses them in the replay. Keep `content`,
// or staging one entity drops another entity's fields from the index.
if (checkStrictSchemaMatch(InsomniaFileSchema, parse(content)).mismatch) {
return content;
}
const canonicalContent = await getCanonicalInsomniaFileContent(content);
// The import fills in what a file is missing, eg. a `__MOCK_SERVER_ID__` server for a mock file
// whose server was spliced out. Writing that would add an entity nobody added, so keep `content`.
if (canonicalContent === null || hasEntityIdsNotIn(canonicalContent, content)) {
return content;
}
return canonicalContent;

React 👍 if this finding helped, 👎 if it did not.

// `content` is spliced together by the visual diff; write it the way Git Sync would export it.
await GitVCS.stagePartialContent(
filepath,
content === null ? null : await canonicalizeInsomniaFileContent(content),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium risk

Staging one entity canonicalizes the index copy and strips unknown fields that HEAD and the workdir still have. req_2 then shows as modified forever: staging it re-canonicalizes to the same index, so it never converges. The banner disappears too — unknownFieldRanges reads the index side. Discarding the phantom change deletes the committed field from disk. The strict-schema guard from the canonical-file finding fixes this path.

React 👍 if this finding helped, 👎 if it did not.

Comment on lines +133 to +146
// Unstaged view only: graft the entity's index/HEAD state (`before`) onto the
// workdir file (`after`), throwing away just this entity's unstaged edits.
const handleDiscard = useCallback(
(entityId: string) =>
runAction(entityId, 'discard', () =>
discardPartialContentFetcher.submit({
filepath,
content: applyEntityChange(after, before, entityId),
projectId,
workspaceId,
}),
),
[runAction, before, after, filepath, projectId, workspaceId, discardPartialContentFetcher],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low risk

A never-staged file has an empty before, so every entity is added and discardable. Discarding the last one writes identity-only YAML to the working tree. The file keeps showing as changed — one workspace card with no actions — until a second, file-level discard. Stage avoids its index-side version of this with content: null; discard has no counterpart. Mirror it, and let the route delete the file with removeLocalOnlyOrphans.

Suggested change
// Unstaged view only: graft the entity's index/HEAD state (`before`) onto the
// workdir file (`after`), throwing away just this entity's unstaged edits.
const handleDiscard = useCallback(
(entityId: string) =>
runAction(entityId, 'discard', () =>
discardPartialContentFetcher.submit({
filepath,
content: applyEntityChange(after, before, entityId),
projectId,
workspaceId,
}),
),
[runAction, before, after, filepath, projectId, workspaceId, discardPartialContentFetcher],
);
// Unstaged view only: graft the entity's index/HEAD state (`before`) onto the
// workdir file (`after`), throwing away just this entity's unstaged edits.
const handleDiscard = useCallback(
(entityId: string) =>
runAction(entityId, 'discard', () => {
const content = applyEntityChange(after, before, entityId);
// A never-staged file loses its last entity: like stage's `null`, drop the
// file instead of leaving identity-only YAML in the working tree.
return discardPartialContentFetcher.submit({
filepath,
content: !before && hasOnlyWorkspaceIdentity(content) ? null : content,
projectId,
workspaceId,
});
}),
[runAction, before, after, filepath, projectId, workspaceId, discardPartialContentFetcher],
);

React 👍 if this finding helped, 👎 if it did not.

@yaoweiprc
yaoweiprc force-pushed the visual-diff-5-discard branch from efca8be to 26091e0 Compare October 19, 2026 07:15
@yaoweiprc
yaoweiprc force-pushed the visual-diff-5-discard branch 3 times, most recently from 669267c to f80863e Compare October 19, 2026 09:38
@yaoweiprc
yaoweiprc force-pushed the visual-diff-5-discard branch 2 times, most recently from c5efa09 to e841209 Compare October 20, 2026 09:20
@yaoweiprc
yaoweiprc force-pushed the visual-diff-5-discard branch 3 times, most recently from 0288066 to 7b97e24 Compare October 20, 2026 10:20
…y Git Sync exports them

Unstaged cards get a click-to-confirm Discard that rewrites only that entity in
the working-tree file and syncs it back into the database; discarding a folder
that only exists in the working tree warns that its contents go with it.

Before a per-entity stage, unstage or discard is written, the grafted file is
replayed through Git Sync's import -> export in a scratch in-memory database,
so it comes out exactly as Insomnia would export it. Files that can't be
replayed, or whose replay would add placeholder entities, are written as is.

Co-authored-by: James Gatz <jamesgatzos@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@yaoweiprc
yaoweiprc force-pushed the visual-diff-5-discard branch from 7b97e24 to 3294f08 Compare October 20, 2026 10:33

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant