diff --git a/README.md b/README.md index 6b0c64e..c7ae18c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 49ca170..543732d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/TASKS.md b/TASKS.md index 912ef76..738481a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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` diff --git a/docs/learnings.md b/docs/learnings.md index 93be37d..bc77940 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -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. diff --git a/ui/src/components/PierreTree.tsx b/ui/src/components/PierreTree.tsx index b76e153..2d329d4 100644 --- a/ui/src/components/PierreTree.tsx +++ b/ui/src/components/PierreTree.tsx @@ -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 ─────────────────────────────────────────────────────── @@ -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; @@ -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; @@ -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. */ @@ -291,7 +292,7 @@ export const PierreTree = forwardRef(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). @@ -304,29 +305,20 @@ export const PierreTree = forwardRef(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], ); @@ -486,9 +478,9 @@ export const PierreTree = forwardRef(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 @@ -496,10 +488,11 @@ export const PierreTree = forwardRef(function .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; } diff --git a/ui/src/lib/treeSelection.test.ts b/ui/src/lib/treeSelection.test.ts new file mode 100644 index 0000000..39c34ef --- /dev/null +++ b/ui/src/lib/treeSelection.test.ts @@ -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', + ]); + }); +}); diff --git a/ui/src/lib/treeSelection.ts b/ui/src/lib/treeSelection.ts new file mode 100644 index 0000000..01100cb --- /dev/null +++ b/ui/src/lib/treeSelection.ts @@ -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; +} diff --git a/ui/src/stores/repo.ts b/ui/src/stores/repo.ts index d400e92..026dd9f 100644 --- a/ui/src/stores/repo.ts +++ b/ui/src/stores/repo.ts @@ -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; diff --git a/ui/src/views/LocalChanges.tsx b/ui/src/views/LocalChanges.tsx index a5cb3e3..5997d24 100644 --- a/ui/src/views/LocalChanges.tsx +++ b/ui/src/views/LocalChanges.tsx @@ -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'; @@ -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 @@ -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); @@ -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': { diff --git a/website/docs/everyday-git.md b/website/docs/everyday-git.md index c9f45a3..7664d74 100644 --- a/website/docs/everyday-git.md +++ b/website/docs/everyday-git.md @@ -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.