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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,9 @@ and the live Diff settings preview.
`.worktreeinclude` (`.env`, local settings) so agents can run out of the
box. Stale entries whose directories are already gone prune immediately.
- **Everyday Git** — stage, unstage, or recoverably discard whole change
blocks or individually selected lines inline in the diff; initialize a
repository with an initial branch, optional
blocks or individually selected lines inline in the diff; bulk tree actions
include every selected file and every changed file beneath selected folders;
initialize a repository with an initial branch, optional
`.gitignore`, and optional first commit; inspect a stash from the sidebar
without applying it, or create and check out a branch from it; fetch / pull /
push with streaming progress and explicit
Expand Down
7 changes: 7 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2415,6 +2415,13 @@ and verified by the hosted post-promotion Linux smoke. Release run
legacy manifests were byte-for-byte identical and advertised helper `1.2.1`
with protocol `5`.

**Mixed tree-selection discard repaired (2026-07-29):** Local Changes now
expands every selected folder to its changed file descendants before Stage,
Unstage, Stash, or Discard resolves its batch. Mixed folder/file selections,
overlapping selections, row context actions, and the `d d` / Delete keyboard
paths all preserve the full intended target set while retaining one bulk IPC
call.

---

## Cross-cutting tracks (run in parallel with all milestones)
Expand Down
3 changes: 2 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,8 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git-
- ☑ Commit kbd shortcut (⌘↵)
- ☑ Per-row Discard action (`FileSection.menuItems` adds a confirm-gated
"Discard…" item to the Unstaged file-row right-click menu, wired to
`discardMany`; acts on the row or the whole multi-selection)
`discardMany`; acts on the row or the whole multi-selection, expanding every
selected folder to its changed descendants via `resolveTreeActionTargets`)
- ✗ Recent-messages dropdown on the subject field — **removed 2026-07-02**
(shipped 2026-05-29, cut on user feedback: resurfacing stale old commit
messages made no sense next to AI suggestions). The `commit_messages`
Expand Down
9 changes: 9 additions & 0 deletions docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -2079,3 +2079,12 @@ older `store-submission` action targets unmanaged MSI/EXE. A successful
workflow submission does not mean certification is complete, and the unsigned
`.msixupload` remains a private Actions artifact. Preserve manual build-only
dispatch so packaging can be diagnosed without mutating Partner Center.

**Pierre action targets must expand every selected directory (2026-07-29).**
Pierre's raw multi-selection contains both file and directory paths. Never
filter it to exact file rows before resolving an action: doing so silently
drops every selected folder from a mixed selection. Expand each selected
directory against the wrapper's known file set, deduplicate overlaps by
iterating that set once, and keep an action on an unselected context row scoped
to that row. `expandTreeSelection` / `resolveTreeActionTargets` own this
boundary; preserve the single batched IPC call after expansion.
39 changes: 16 additions & 23 deletions ui/src/components/PierreTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { FileTreeDirectoryHandle, FileTreeItemHandle, GitStatus, GitStatusE

import { ContextMenu, type MenuItem } from './ContextMenu';
import { TREE_ICONS } from '../lib/treeIcons';
import { expandTreeSelection, resolveTreeActionTargets } from '../lib/treeSelection';
import type { DiffStatus } from '../lib/types';

// ─── status mapping ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -57,7 +58,7 @@ export interface TreeRowDecoration {
/** Imperative handle so a host can open Pierre's in-tree search on demand. */
export interface PierreTreeHandle {
openSearch(): void;
/** Current Pierre multi-selection (file paths only). */
/** Current Pierre multi-selection expanded to concrete file paths. */
getSelectedPaths(): string[];
/** Expand a mounted directory after lazy children have been added. */
expandPath(path: string): void;
Expand All @@ -77,7 +78,7 @@ interface PierreTreeProps {
selectedPath?: string | null;
/** Fired with the active (last-selected) path and its kind, or `null` when the selection empties. */
onSelect?: (path: string | null, kind: 'file' | 'directory' | null) => void;
/** Fired whenever Pierre's multi-selection changes (file paths only). */
/** Fired whenever Pierre's multi-selection changes, with directories expanded to files. */
onMultiSelectionChange?: (paths: string[]) => void;
/** Fired when a closed directory is activated so hosts can load children. */
onDirectoryExpand?: (path: string) => void;
Expand All @@ -99,7 +100,7 @@ interface PierreTreeProps {
menuItems?: (paths: string[], context: TreeMenuContext) => MenuItem[];
/**
* Discard the resolved target file set — bound to the Delete / Backspace
* keys while a file row is focused. Resolves the same way the context menu
* keys while a file or folder row is focused. Resolves the same way the context menu
* does: a focused row inside a multi-selection discards the *whole*
* selection, not just the active row. Omit to disable.
*/
Expand Down Expand Up @@ -291,7 +292,7 @@ export const PierreTree = forwardRef<PierreTreeHandle, PierreTreeProps>(function
onSelectionChange: (sel) => {
if (reflecting.current) return; // our own rewrite, not a user gesture
const files = fileSetRef.current;
const fileSel = sel.filter((p) => files.has(p));
const fileSel = expandTreeSelection([...files], sel);
onMultiSelectionChangeRef.current?.(fileSel);
const next = sel.length ? sel[sel.length - 1] : null;
// Ignore the echo of our own reflection (covers null === null too).
Expand All @@ -304,29 +305,20 @@ export const PierreTree = forwardRef<PierreTreeHandle, PierreTreeProps>(function
ref,
() => ({
openSearch: () => model.openSearch(),
getSelectedPaths: () => model.getSelectedPaths().filter((p) => fileSetRef.current.has(p)),
getSelectedPaths: () =>
expandTreeSelection([...fileSetRef.current], model.getSelectedPaths()),
expandPath: (path) => asDir(model.getItem(path))?.expand(),
}),
[model],
);

// Resolve the file set an action targets, given the row it was invoked on:
// a selected file row with a multi-selection → the whole selection; a folder
// row → every known file beneath it; otherwise → just that file.
// a selected row with a multi-selection → the whole expanded selection; an
// unselected folder row → every known file beneath it; otherwise → that file.
const resolveTargets = useCallback(
(rowPath: string): string[] => {
const files = fileSetRef.current;
if (files.has(rowPath)) {
const selected = model.getSelectedPaths();
if (selected.length > 1 && selected.includes(rowPath)) {
return selected.filter((p) => files.has(p));
}
return [rowPath];
}
const prefix = rowPath.replace(/\/+$/, '') + '/';
const out: string[] = [];
for (const p of files) if (p.startsWith(prefix)) out.push(p);
return out;
return resolveTreeActionTargets([...files], model.getSelectedPaths(), rowPath);
},
[model],
);
Expand Down Expand Up @@ -486,20 +478,21 @@ export const PierreTree = forwardRef<PierreTreeHandle, PierreTreeProps>(function
}
// Delete / Backspace discard the focused row in a single press, acting on
// the whole multi-selection (resolveTargets) when the focused row is part
// of one. stopPropagation keeps the window-level shortcut from also firing
// and double-discarding. Guarded to file rows so Backspace in the search
// box still edits text.
// of one. Folder selections expand to their descendant files.
// stopPropagation keeps the window-level shortcut from also firing and
// double-discarding. The on-row guard keeps Backspace in search editable.
if (e.key === 'Delete' || e.key === 'Backspace') {
if (!onDiscardRef.current) return;
const onRow = e.nativeEvent
.composedPath()
.some((n) => n instanceof HTMLElement && n.dataset.type === 'item');
if (!onRow) return; // focus is in the search box, not a row
const focused = model.getFocusedPath();
if (focused && fileSetRef.current.has(focused)) {
const targets = focused ? resolveTargets(focused) : [];
if (targets.length > 0) {
e.preventDefault();
e.stopPropagation();
onDiscardRef.current(resolveTargets(focused));
onDiscardRef.current(targets);
}
return;
}
Expand Down
65 changes: 65 additions & 0 deletions ui/src/lib/treeSelection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';

import {
expandTreeSelection,
resolveActiveTreeTargets,
resolveTreeActionTargets,
} from './treeSelection';

const files = [
'docs/guide.md',
'src/app.ts',
'src/lib/a.ts',
'src/lib/b.ts',
'standalone.ts',
];

describe('expandTreeSelection', () => {
it('expands folders and keeps explicitly selected files', () => {
expect(expandTreeSelection(files, ['src/lib/', 'standalone.ts'])).toEqual([
'src/lib/a.ts',
'src/lib/b.ts',
'standalone.ts',
]);
});

it('deduplicates overlapping folder and file selections', () => {
expect(expandTreeSelection(files, ['src/', 'src/lib/a.ts'])).toEqual([
'src/app.ts',
'src/lib/a.ts',
'src/lib/b.ts',
]);
});
});

describe('resolveTreeActionTargets', () => {
it('acts on every selected folder and file when invoked inside the selection', () => {
expect(
resolveTreeActionTargets(files, ['src/lib/', 'standalone.ts'], 'standalone.ts'),
).toEqual(['src/lib/a.ts', 'src/lib/b.ts', 'standalone.ts']);
});

it('keeps an unselected context row scoped to that row', () => {
expect(
resolveTreeActionTargets(files, ['src/lib/', 'standalone.ts'], 'docs/'),
).toEqual(['docs/guide.md']);
});
});

describe('resolveActiveTreeTargets', () => {
it('keeps a mixed expanded selection for view-level shortcuts', () => {
expect(
resolveActiveTreeTargets(
files,
['src/lib/a.ts', 'src/lib/b.ts', 'standalone.ts'],
'src/lib/',
),
).toEqual(['src/lib/a.ts', 'src/lib/b.ts', 'standalone.ts']);
});

it('falls back to the active row when the stored selection is stale', () => {
expect(resolveActiveTreeTargets(files, ['docs/guide.md'], 'standalone.ts')).toEqual([
'standalone.ts',
]);
});
});
53 changes: 53 additions & 0 deletions ui/src/lib/treeSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** Expand file and directory selections into the concrete file paths they cover. */
export function expandTreeSelection(
filePaths: readonly string[],
selectedPaths: readonly string[],
): string[] {
const selected = new Set(selectedPaths.map((path) => path.replace(/\/+$/, '')));
return filePaths.filter((file) => {
if (selected.has(file)) return true;
let separator = file.lastIndexOf('/');
while (separator >= 0) {
if (selected.has(file.slice(0, separator))) return true;
separator = file.lastIndexOf('/', separator - 1);
}
return false;
});
}

/**
* Resolve the concrete files for an action invoked on a tree row.
*
* An invoked row inside a multi-selection acts on the full selection, including
* every descendant of selected directories. An unselected row stays scoped to
* itself so context-menu actions never mutate an unrelated ambient selection.
*/
export function resolveTreeActionTargets(
filePaths: readonly string[],
selectedPaths: readonly string[],
rowPath: string,
): string[] {
const row = rowPath.replace(/\/+$/, '');
const rowSelected = selectedPaths.some((path) => path.replace(/\/+$/, '') === row);
return expandTreeSelection(
filePaths,
rowSelected && selectedPaths.length > 1 ? selectedPaths : [row],
);
}

/**
* Resolve an action driven by a separately stored active row plus an already
* expanded tree selection. This is used by view-level keyboard shortcuts that
* run outside Pierre's event boundary.
*/
export function resolveActiveTreeTargets(
filePaths: readonly string[],
selectedFilePaths: readonly string[],
activePath: string,
): string[] {
const direct = expandTreeSelection(filePaths, [activePath]);
const available = new Set(filePaths);
const selected = selectedFilePaths.filter((path) => available.has(path));
const selectedSet = new Set(selected);
return direct.some((path) => selectedSet.has(path)) ? selected : direct;
}
2 changes: 1 addition & 1 deletion ui/src/stores/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export interface RepoState {
unstagedDiffs: FileDiff[];
stagedDiffs: FileDiff[];
localSelection: LocalSelection | null;
/** Pierre multi-select per side — mirrors Local Changes tree selection. */
/** Pierre multi-select per side, with selected folders expanded to their files. */
localTreeSelection: { unstaged: string[]; staged: string[] };
setLocalTreeSelection(staged: boolean, paths: string[]): void;

Expand Down
15 changes: 13 additions & 2 deletions ui/src/views/LocalChanges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type SliceDirection,
} from '../lib/patch';
import { treeFileOrder } from '../lib/treeOrder';
import { resolveActiveTreeTargets } from '../lib/treeSelection';
import type { LocalSelection } from '../stores/repo';
import { useRepo } from '../stores/repo';
import { useSettings } from '../stores/settings';
Expand Down Expand Up @@ -206,6 +207,16 @@ export function LocalChanges({ onOpenFileInEditor }: { onOpenFileInEditor: (file

const state = useRepo.getState();
const sel = state.localSelection;
const discardSelection = () => {
if (!sel) return;
const pool = sel.staged ? state.stagedDiffs : state.unstagedDiffs;
const treeSelection = sel.staged
? state.localTreeSelection.staged
: state.localTreeSelection.unstaged;
void state.discardMany(
resolveActiveTreeTargets(pool.map((diff) => diff.path), treeSelection, sel.file),
).catch(fail('Discard'));
};

// Nav order: unstaged → staged, each in the tree's *display* order
// (folders first, natural sort) so j/k step straight down the list the
Expand Down Expand Up @@ -262,7 +273,7 @@ export function LocalChanges({ onOpenFileInEditor }: { onOpenFileInEditor: (file
if (confirmDiscard === sel.file) {
if (confirmTimer.current) window.clearTimeout(confirmTimer.current);
setConfirmDiscard(null);
void state.discardMany([sel.file]).catch(fail('Discard'));
discardSelection();
} else {
setConfirmDiscard(sel.file);
if (confirmTimer.current) window.clearTimeout(confirmTimer.current);
Expand All @@ -278,7 +289,7 @@ export function LocalChanges({ onOpenFileInEditor }: { onOpenFileInEditor: (file
e.preventDefault();
if (confirmTimer.current) window.clearTimeout(confirmTimer.current);
setConfirmDiscard(null);
void state.discardMany([sel.file]).catch(fail('Discard'));
discardSelection();
break;
}
case 'c': {
Expand Down
1 change: 1 addition & 0 deletions website/docs/everyday-git.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Right-click a single file in Local Changes or Review and choose **Open in editor

- The view opens with a "show all" stacked diff of every changed file. Clicking the Unstaged or Staged column title re-selects that side's full changeset, and selecting a folder row aggregates the diffs beneath it.
- Stage or unstage a whole file from its row, or use **Stage all** / **Unstage all** for the whole side.
- Multi-select files and folders with `Mod`-click or Shift-click. Stage, Unstage, Stash, and Discard act on every selected file plus every changed file beneath each selected folder.
- **Block and line staging**: each change block in the diff has inline **Stage** and **Discard** buttons (**Unstage** on the staged side). Drag across changed line numbers to act on a contiguous range, or choose **Lines…** for a keyboard-operable checklist that can select any combination of deleted and added lines. The action labels show the selected-line count.
- **Discarding a change block or selected lines is recoverable**: it shows an Undo toast for a few seconds. Whole-file and bulk discards are immediate and permanent — there is no Undo toast and no automatic safety stash — so stash first if you might want the changes back.
- Toggle between stacked and split diff layout with the header buttons; the choice is remembered per repository. `Mod+F` opens in-diff text search.
Expand Down
Loading