# Feature list: item by item against JetBrains

> This table answers one question: the move you are used to in IDEA — is it here, and where does it stop?
> Design trade-offs and "why not do it that way" live in [design notes](jetbrains-parity.md); the interface layer is [api.md](api.md).

## Staging and committing

| JetBrains | How it works here | Status |
|---|---|---|
| Local Changes tree (Staged / Unstaged / Unversioned / Conflicts) | Four collapsible lists, the checkbox stages or unstages; the conflict group carries resolution actions of its own | ✅ |
| Stage hunk by hunk | "Stage this hunk" on the diff block header → trim the patch → `git apply --cached` | ✅ |
| Stage line by line | Click the `+`/`−` in the line gutter to select several lines, then "Stage selected lines" | ✅ |
| Commit window (message + Amend + commit) | The bottom-left panel, `Ctrl+Enter` commits | ✅ |
| Changelists | Changes in the panel are grouped by changelist; named lists can be created beyond the default one; "Move to changelist…" on a file moves it between lists, and a list's "Commit this list" commits only what that list has staged — through a temporary index, with the other lists' staged content left in the index untouched (a staged rename is not swept into someone else's commit). Grouping is stored in the browser per repository, never written into the repository; lists can be renamed, and deleting one asks twice and says the files go back to the default list | ✅ |
| Before Commit checks | The "Checks" menu in the commit bar reads runnable scripts from `package.json` (detecting npm/pnpm/yarn/bun, filtering out long-running ones like `start`/`dev`), and the ticks persist per repository; checks run serially before the commit, and a failure opens a dialog with the command, exit code, duration and full output, defaulting to cancel with an explicit "Commit anyway"; "Run the selected checks now" runs only the checks and offers no commit | ✅ |
| AI commit message | The `AI` button in the commit panel (`Ctrl+G`), through the same model routing the conversation uses | ✅ |
| Commit-message recall | `↑`/`↓` in an empty message box walk back through recent messages (with a draft, `↑` does not overwrite it and the cursor leaving the start of the line hands editing back); the "Recent messages" menu keeps the mouse path; both keys are written in the box's placeholder and the menu tooltip | ✅ |
| Undo Commit | Only the HEAD row offers "Undo commit…": a soft reset to the first parent, with the undone changes landing back in the index; unstaged edits and untracked files in the worktree are not touched by a single byte, and nothing but the branch pointer is rewritten. A root commit has no parent and does not show the item; it is refused while a merge/rebase/cherry-pick is paused, so the sequencer is never pulled out from under HEAD | ✅ |
| Reword | "Edit commit message…" on a commit in the History page: the dialog loads the full original (multi-line and editable, `Ctrl+Enter` saves, `Esc` cancels) and changes text only, never the tree. HEAD goes through amend; an earlier commit replays a same-order rebase with a reword step, and the dialog says the later hashes will change. Refused on a dirty worktree, and a merge or root commit gets a concrete reason | ✅ |

## Reading diffs

| JetBrains | How it works here | Status |
|---|---|---|
| Side-by-side / unified diff | Toolbar toggle, the choice persists to localStorage | ✅ |
| Inline word-level highlighting | The common prefix and suffix of a replaced line are stripped word by word (token LCS), so both unified and side-by-side mark only the words that really changed, layered under syntax coloring | ✅ |
| Ignore Whitespace | An "Ignore whitespace" toggle in the toolbar (persisted); only non-whitespace changes are shown, and the state is flagged with an accent background. The mode is read-only: block/line indices come from the `-w` result, so staging or discarding would land on the wrong lines — therefore the block buttons, file staging, line selection and `Ctrl+Alt+R` are all hidden, the header reads "Ignore whitespace · read-only", and an empty result shows a dashed empty-state card with a one-click "Show all changes" | ✅ |
| Move between files in a diff | `Alt+↑/↓`, the header's `‹ n/m ›`, and clicking `3 / 5` opens the full file list | ✅ |
| Annotate (Blame) | The "Annotate" button in the diff header (one entry point for on and off, the title spells out the shortcut and the meaning): author/commit/date per line — consecutive lines from one commit are labelled once (13 labels across 76 lines), uncommitted lines read `Uncommitted`, the code keeps its syntax coloring, and the author and line-number columns are sticky, so scrolling horizontally through a long line never loses "who wrote this" | ✅ |
| Rename detection | In the changes tree an `R` row is followed by the old file name (`app.spec.js tests ← app.test.js`), instead of flinging it to the far right of the row where it reads like another column | ✅ |
| File history | "Show file history" in the file row's menu → the History page filtered by path, with a dismissable scope chip on the filter bar | ✅ |

## Commit history

| JetBrains | How it works here | Status |
|---|---|---|
| Commit graph (branch lanes) | The "History" page, a DAG split into lanes with colored edges and branch/HEAD labels | ✅ |
| Commit details + per-file diff | Clicking a commit expands it on the right | ✅ |
| Keyboard navigation in History | `↑`/`↓` step through commits (the list follows and the details load with it), `Home`/`End` jump to the ends, `Enter` or a double click opens that commit's full diff, `F5` refreshes | ✅ |
| History readings | With a filter on, the header switches to `4 of 11 commits matched` (it used to always report the total, so a filter looked broken); commit hashes and messages are one-click copyable (context menu + click in the details); when a branch label and a message compete for width the label yields first, with the full name in the tooltip | ✅ |
| Multi-select in History | `Ctrl/⌘` click appends, `Shift` click selects a run, `Ctrl+A` selects every commit currently visible, `Esc` clears; a selection grows a toolbar: a "N selected" count badge on the left, and on the right one group of buttons for cherry-pick / revert / copy hash / copy message and a second group for "Show only selected" and "Clear selection". Batch cherry-pick and batch revert replay in commit order host-side (the earlier one lands first), so a batch produces the same result as writing them out one by one in that order; the context menu on a selected row targets the whole batch, not just that one commit | ✅ |
| Structured filtering in History | The History search box understands six conditions — `author:` `message:` `hash:` `tag:` `branch:` `date:` — with quotable values (`author:"Ada Lovelace"`), intersected with each other; `date:` accepts periods (`2026-10-15` is that day, `2026-10` is that month) and may be prefixed with `>` `<` `>=` `<=`; an unrecognised `xx:` stays an ordinary word, so a message like `fix: crash` is not lost; each condition is a chip under the box that can be removed on its own (blue for a condition, gray for free text, outlined for a field still waiting for a value), and the `▾` at the right of the box lists every field, filling its prefix in on click | ✅ |
| Grouping in History | To the right of the filter row an "Ungrouped ▾" control files commits into sections by **branch**, **author** or **date**: each section gets a collapsible header (kind icon + name + commit count), and one click folds the whole group; each group redraws the graph from its own commits — a group is that group's history; a commit belongs to exactly one group, and commits no branch points at go to "No branch". Grouping is layout only — multi-select, cherry-pick and revert still act on the whole batch — but `Ctrl+A` selects only the commits **you can see** (a folded group does not count) | ✅ |
| History columns | The History page is a table: clicking "Message / Author / Date / Hash" in the header sorts by that column (click again to flip), and the current sort column carries an arrow; to the right of the filter row a "Columns ▾" menu toggles each column (the menu stays open so several can be adjusted in one go). Every column has a vertical line at its right edge and that is where you drag to resize (it thickens and turns blue on hover): message, graph, author, date and hash are each independent, and **double-clicking that line resets the column**; dragged widths are remembered locally. The wider the panel, the wider the columns can go: at bottom-dock width, dragging the graph column open shows dozens of lanes; when the panel narrows, over-wide columns are clamped back to what fits instead of pushing a horizontal scrollbar onto the table. When the panel cannot satisfy every column, **the column you touched last gets its width first** and the rest give way in proportion to what each can spare; to change the priority, drag that column. **The graph is drawn only in history order (newest first)** — under another sort the parent/child relationship it expresses no longer holds, so it is folded away; the filter row then shows a "Restore commit graph" button, and one click returns to history order. Narrow columns yield by the width they live in: the author column (under 460 px) and the hash column (under 340 px) step aside, header and data rows together, so the two stay aligned. The sort and the column toggles are remembered locally, and the next open looks the same | ✅ |
| Branch labels in narrow columns | Each row's branches and tags are **written out in full**, not fragments: as the panel narrows the message yields first and the label keeps its name (`origin/feature/diff-review` stays `origin/feature/diff-review`, never `or…`). Labels that do not fit on one row fold into a `+N` badge that says how many more there are, and the tooltip lists the full names of the folded ones; the first label (usually the current branch) is always there, shortening itself rather than being dropped when it gets too long. The current branch's label is solid, the rest are pale | ✅ |
| Cherry-pick / Revert / Reset | Commit row menu: cherry-pick, revert this commit, reset (soft/hard) to this commit — every destructive one asks first | ✅ |
| Interactive rebase | "Interactive rebase onto this commit" in a commit's context menu: the dialog lists the commits after base in execution order, movable up and down, each set to keep / squash (keeps both messages) / fixup (keeps the first) / drop, with a live footer reading "keep x / y · produce z commits"; dropping everything or squashing into the first is caught and the button disabled. On a conflict the top status bar turns red and states the conflict count, and "Continue" stays disabled until every conflict is resolved; when a replay comes out empty it pauses and explains "this commit brings no new changes, click continue to skip it", and the next click finishes — the terminal is never needed | ✅ |

## Branches, remotes and the network

| JetBrains | How it works here | Status |
|---|---|---|
| Switch / create / rename a branch | The branch chip popover in the toolbar; renaming keeps the upstream and says the upstream still carries the old name | ✅ |
| Delete a local branch | "Delete branch" from the row's ⋯ or the context menu, with the branch named in the confirm. When the branch tracks a remote branch that is still listed under remotes, the dialog adds an unchecked "Also delete the remote branch origin/xxx it tracks" box; the local delete happens first and only then `git push <remote> --delete`, and a failure on the remote is reported as-is without undoing the local delete. A `-d` refusal for unmerged work still asks once before `-D` | ✅ |
| Remote branch actions | Every remote branch is a full row like a local one: one click checks it out (creating a tracking local branch when the name is free), and a row-end ⋯ — plus the context menu, which offers the same list — gives "Switch to this branch / Create a local branch from it… / Copy branch name / Delete remote branch". The create prompt is prefilled with the branch half of the name, and the new branch explicitly `--track`s its upstream regardless of `branch.autoSetupMerge`; deleting goes through `git push <remote> --delete`, asks twice, names `origin/xxx` and says the remote repository drops it too, and a default branch is refused host-side | ✅ |
| Branch ahead / behind | `↑2` / `↓1` right on the branch chip (green ahead, red behind), with "2 commits ahead of upstream" on hover; the chip itself says which remote branch is tracked. A branch with no upstream shows no markers — there is nothing to compare against | ✅ |
| Set / Unset Upstream | "Set upstream…" on the current branch: pick one of the fetched remote branches and it becomes `git branch --set-upstream-to`; or "Type a remote branch name…" for a name that has not been fetched yet. With an upstream already set the items read "Change upstream…" and "Unset upstream". The upstream name is validated host-side (the `remote/branch` halves are checked separately, the remote name cannot contain `/`), and the branch it names must exist, so you get "remote branch origin/nope not found" rather than a config entry pointing at nothing. Unsetting the upstream loses no commit, so it has no confirmation | ✅ |
| Compare branches (Show Changes) | "Compare with current branch" on a branch row → the History page lists only the commits unique to each side, with ahead/behind in the header | ✅ |
| Rebase onto a branch | "Rebase current branch onto this branch…" on a branch row: replay the commits unique to the current branch on top of the chosen one (`git rebase <branch>`). The item does not appear on the current branch's own menu, so the target cannot be confused; there is a dedicated confirmation that says "hashes will change / the worktree must be clean / a conflict pauses at the top status bar", with focus defaulting to "Cancel"; a dirty worktree, a sequencer already running, a target equal to itself or a target that does not exist are all refused explicitly (in Chinese, not raw English git output); on a conflict the rebase pauses and the hint points at the top status bar to continue or abort | ✅ |
| Fetch / Pull / Push | The branch chip popover: pull (merge and rebase variants), push (including force push, via `--force-with-lease`), fetch. While one runs, the chip itself becomes "Fetching…" with a pulse and a progress cursor, and the cell running in the popover stays highlighted — no guessing whether the click landed; clicking any other action in the meantime says "the previous Git operation is still running" instead of swallowing it. A first push asks which remote to push to (`origin` is a convention, not a fact): with an upstream it pushes straight away, without one it offers the remote list and pushes with `-u <remote> HEAD`, so nobody has to set the upstream in a terminal first; on failure git's own words appear in a left-aligned popover, with the `hint:` boilerplate stripped and anything past 6 lines truncated — no longer a wall of English covering the commit box | ✅ |
| Manage Remotes | "Manage remotes…" at the bottom of the branch chip popover: lists every remote in the repository (name + URL), and lets you add, re-point and remove them. The URL is exactly what `git remote add/set-url` takes — a local path, ssh, https; names and URLs are validated host-side (a name allows letters, digits and `._-` only; a URL cannot be empty or start with `-`, which git would read as an option). Deleting asks twice and states "already fetched remote branches stay until the next fetch prunes them", with focus defaulting to "Cancel" | ✅ |

## Stash, conflicts and interrupted operations

| JetBrains | How it works here | Status |
|---|---|---|
| Shelf / Stash | "Stash changes…" in the branch menu first opens a naming and selection dialog: the message is prefilled with "Stash · time" (empty uses git's default name) and every file is ticked; ticking only a few files produces a partial stash through a temporary index, so unticked staged content is neither stashed nor disturbed. The menu's bottom lists the 5 most recent stashes: a single click opens a read-only preview (including untracked files, via `git stash show --include-untracked`), and the context menu is where apply / pop / copy ref / drop live; dropping asks twice, so a misclick does not rewrite the worktree | ✅ |
| Conflict resolution | A three-way conflict view: per block "Take yours / Take theirs / Accept both", or "Edit result" to write the merged text straight under the block (`Ctrl+Enter` applies, `Esc` cancels), with the typed content replacing only that block; the common-ancestor column can be shown, and narrow screens stack it; long unchanged stretches between two conflicts fold the way a diff does (3 lines of context per side, click to expand), and resolving the last block leaves the conflict editor automatically | ✅ |
| Merge / cherry-pick / rebase in progress | The top status bar names the operation and how many conflicts remain, and offers "Abort" (`--abort`); a non-merge operation reveals "Continue" once every conflict is resolved, and can continue to skip an empty replay too. While paused the commit bar stays disabled and points at "Continue", so no extra commit is ever built on top of the sequencer | ✅ |

## Panel and interaction details

| JetBrains | How it works here | Status |
|---|---|---|
| Watch a conversation's changes, one by one or turn by turn | The turn card, the right sidebar's "Changes" tab, the conversation header, and the `Git` button on the right sidebar's "Files" tab | ✅ |
| A persistent change hint | The change bar above the input (appears only when there are changes; clicking opens the right sidebar tab) | ✅ |
| A directory that is not a repository | The empty state says only "this directory has no repository" and offers one-click "Initialize repository": `git init` in place (creating only `.git`, touching no existing file), after which it refreshes into a branch chip + untracked files; the branch chip, the status pill row and the commit box are not rendered at all (a disabled "Commit" would only leave the user guessing why) | ✅ |
| Create a Git repository in an empty directory | The empty-state "Initialize repository" button goes through the host's `POST /api/git/init` (write lock, absolute path + directory checks, refused in read-only mode); when it already is a repository (including under a parent repository) it returns idempotently and refreshes, never nesting a second `.git`; in practice existing files are left exactly as they were and appear in the untracked list right away | ✅ |
| Destructive confirmations | All 16 confirmations name their action on the button (Discard changes / Hard reset / Reset and unstage / Drop stash / Delete branch / Delete changelist / Remove remote / Rebase onto this branch / Force push / Take theirs …) and are red, with focus parked on "Cancel" — Enter can never press an irreversible button for the user; a soft reset keeps the changes but empties the index, so it asks too | ✅ |
| Floating name dialog (new branch/tag, rename) | The suggested name is typed into the input and selected (a placeholder can neither be accepted with Enter nor edited), the button names the verb — Create / Rename — and an empty Enter turns the border red with "The name cannot be empty" instead of refusing silently; "Enter to confirm · Esc to cancel" is always shown | ✅ |
| Settings grouping | Host settings → Built-in plugins → Git workbench: 13 items under four group headings ("Commit and branch / Diff and history / Model for commit messages / Safety and read-only"), with the save bar sticky at the bottom of the dialog | ✅ |
| Git console | A separate tab lists the git commands the host really ran, per repository: argv, exit code, duration, time; a failed row is red and expands its stderr, a run of identical commands folds into `×N`, and refreshing records never buries a failure; "Clear" clears only the current repository's history, and polling the console itself never adds records | ✅ |
| Controls look the same everywhere | Secondary controls in a toolbar (the commit bar's `AI` / `Recent messages` / `Checks`, the diff toolbar's "Ignore whitespace / Unified / Stage whole file", the diff block's "Stage this hunk", the History page's "All branches ▾", the conflict view's "Take yours everywhere" …) carry a border, so they read as clickable at a glance; in-row actions (a changelist's "Commit this list", a group header's "Stage all / Unstage all", the conflict view's "Edit result / Take yours / Accept both") are blue text links; read-only numbers (the conflict view's `1 / 3` block counter) stay neutral gray and do not pretend to be actions | ✅ |
| Chinese and English | Follows DSH's language preference (the browser language when unset), switching live; an already-open tab title needs the tab reopened (the host's own tab bar behaves the same way), and the commit-message language can be pinned with `commitLanguage` | ✅ |

## Layout breakpoints

The tool window gets dragged narrow and expanded, so all three panes size their contents:

| Tab | Width | Layout |
|---|---|---|
| Commit | any | The list fills the pane with the commit bar pinned to the bottom; the message box on top, `AI`/`Recent messages`/`Checks`/`Amend`/`Commit` on one row below; header controls tighten under 640 px |
| History | ≥ 560 px | Graph on the left, details on the right |
| History | < 560 px | Stacked, the author column hidden and the graph lanes narrowed |
| History | no commit selected | The list takes the full height and the details leave a one-line hint |
| Diff | ≥ 820 px | Side by side (a manual choice overrides it) |
| Diff | < 820 px | Unified (side by side is meaningless at this width) |
| Conflict | ≥ 760 px | Yours / theirs as two columns |
| Conflict | < 760 px | The two columns stack |

The diff toolbar groups controls by "what they act on", so two sets of arrows never read as one control: `∧ 2/3 ∨` navigates hunks **within the current file** (the count sits between the arrows, and the tooltip spells out which hunk), `‹ 2 / 8 ›` switches **between files**, and `+6 −2` only reports line counts. The counter follows the reading position; when the whole diff is short enough not to scroll, the last click takes over instead — otherwise buttons in this narrow sidebar would do nothing.

## Known limitations

1. **Commits follow the index**: the checkbox is a shortcut for "stage/unstage", and a commit always commits the index — the unstaged part is never swept along.
2. **A new file can only be staged whole**: partial staging depends on an old side existing, and an added file has no pre-image to align against.
3. **Each conflict block can be hand-edited, but the edit is scoped to that block**: taking a side (yours / theirs / both) and "Edit result" are alternatives; the box is prefilled with your side, and what you write replaces only the current block, with the other markers left alone. Stitching across blocks is still an external editor's job — come back and press `F5` to carry on block by block. A delete/modify conflict has no common text to compare, so the view says so instead of drawing three empty columns. After taking the local version everywhere the worktree may show no diff, and the top status bar then reads "merge in progress · ready to commit", with both committing and aborting entering from there.
4. **Ignore-whitespace mode is read-only**: `-w` computes different hunk indices than a plain diff, so writing back would land on the wrong lines — the mode therefore has no write entry point at all.
5. **The commit graph lays out lanes with a single greedy pass**, so extremely interleaved branches may be less compact than IDEA's.
6. **Large files**: a single file's diff over 32 MB is refused, so the browser does not get blown up.
7. **Turn snapshots are readable only while the host process lives**: DSH keeps only the `workspace/changes` summaries in memory, so an old turn cannot be opened after a restart (the panel says why).
8. **AI commit messages need model credentials**: they go through the same provider as `ctx.llm`, and a missing key does not error out but degrades to the local rules with the reason shown.
9. **The chat's `edit` / `write` tool cards are not taken over**: that is a slot replaced by key, and the cost outweighs the benefit — see [architecture.md](architecture.md#every-place-in-dsh-that-shows-a-diff).
