feat(lanes): full-width Changes, Stage all, one-read expand-all
- The Changes panel's width control gains a full-width step (narrow → 50/50 → 70/30 → full → closed); the console beside it is hidden with CSS, never unmounted. - "Expand all folders" is one GET /api/lanes/:id/git/tree-all (one `git ls-files`, every level shaped like /git/tree) instead of one request and one git process per folder. - Overview: a Stage all button and the staged-only commit box sit above the file list and stay pinned while it scrolls; the Files finder is pinned too.
This commit is contained in:
@@ -60,8 +60,6 @@ const OVERVIEW_REFRESH_MS = 15_000;
|
||||
const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000];
|
||||
|
||||
const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
|
||||
/** Tree reads "expand all" keeps in flight at once. */
|
||||
const TREE_CONCURRENCY = 4;
|
||||
|
||||
type Mode = "overview" | "review" | "file" | "history" | "browse";
|
||||
|
||||
@@ -425,47 +423,18 @@ export function LaneChangesPanel({
|
||||
};
|
||||
|
||||
const [treeBusy, setTreeBusy] = useState(false);
|
||||
// Which lane `treeChildren` was filled for; the reset effect moves it.
|
||||
const treeLane = useRef(laneId);
|
||||
|
||||
// Fetches whatever level isn't cached yet, depth-first, so "expand all"
|
||||
// works even on a tree nothing has been opened in.
|
||||
// "Expand all" is ONE read of the whole tree: a request per folder meant a
|
||||
// `git ls-files` per folder, seconds of waiting on a large repo.
|
||||
const expandAllDirs = async () => {
|
||||
// A walk is many reads long; a lane switch mid-walk must not land lane A's
|
||||
// tree in lane B, nor start from a cache the switch has not cleared yet.
|
||||
const lane = laneId;
|
||||
setTreeBusy(true);
|
||||
const cache = treeLane.current === lane ? { ...treeChildren } : {};
|
||||
const toExpand = new Set(expandedDirs);
|
||||
// Level by level, at most TREE_CONCURRENCY reads in flight: one request
|
||||
// per folder all at once is thousands on a monorepo.
|
||||
const visit = async (path: string, next: string[]) => {
|
||||
let entries = cache[path];
|
||||
if (!entries) {
|
||||
entries = (await api.lanes.gitTree(lane, path)).entries;
|
||||
cache[path] = entries;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (!e.dir) continue;
|
||||
toExpand.add(e.path);
|
||||
next.push(e.path);
|
||||
}
|
||||
};
|
||||
try {
|
||||
for (let level = [""]; level.length && laneNow.current === lane; ) {
|
||||
const next: string[] = [];
|
||||
// Workers share one iterator, so each path is taken exactly once.
|
||||
const it = level.values();
|
||||
await Promise.all(
|
||||
Array.from({ length: TREE_CONCURRENCY }, async () => {
|
||||
for (const path of it) await visit(path, next);
|
||||
})
|
||||
);
|
||||
level = next;
|
||||
}
|
||||
const { levels } = await api.lanes.gitTreeAll(lane);
|
||||
// A lane switch mid-read must not land lane A's tree in lane B.
|
||||
if (laneNow.current !== lane) return;
|
||||
setTreeChildren(cache);
|
||||
setExpandedDirs(toExpand);
|
||||
setTreeChildren(levels);
|
||||
setExpandedDirs(new Set(Object.keys(levels).filter(Boolean)));
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -516,7 +485,6 @@ export function LaneChangesPanel({
|
||||
setSha(null);
|
||||
setRange("work");
|
||||
setTreeChildren({});
|
||||
treeLane.current = laneId;
|
||||
setExpandedDirs(new Set());
|
||||
setTreeBusy(false);
|
||||
setBusy(false);
|
||||
@@ -561,12 +529,17 @@ export function LaneChangesPanel({
|
||||
// An action that resolves after a lane switch belongs to the lane left: its
|
||||
// `load` is the pre-switch one and would take a fresh ticket and paint the
|
||||
// old lane's files here, so everything after the await is dropped.
|
||||
const act = async (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => {
|
||||
const act = async (
|
||||
action: "stage" | "unstage" | "discard",
|
||||
path: string | string[],
|
||||
confirm?: boolean
|
||||
) => {
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(lane, action, { paths: [path], ...(confirm ? { confirm } : {}) });
|
||||
const paths = Array.isArray(path) ? path : [path];
|
||||
await api.lanes.gitAction(lane, action, { paths, ...(confirm ? { confirm } : {}) });
|
||||
if (laneNow.current !== lane) return;
|
||||
setConfirmDiscard(null);
|
||||
refreshGit();
|
||||
@@ -617,6 +590,8 @@ export function LaneChangesPanel({
|
||||
const files = data?.available ? data.files : [];
|
||||
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
|
||||
const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0;
|
||||
// What "Stage all" sends: every listed file with a change the index lacks.
|
||||
const toStage = range === "work" ? files.filter((f) => f.untracked || f.unstaged) : [];
|
||||
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
|
||||
|
||||
// The armed path left the list (committed, reverted, lane switched): disarm,
|
||||
@@ -836,7 +811,9 @@ export function LaneChangesPanel({
|
||||
</ul>
|
||||
) : mode === "browse" ? (
|
||||
<div data-testid="lane-changes-browse">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
{/* Pinned: the finder is what you reach for after scrolling a
|
||||
long tree, not only from its top. */}
|
||||
<div className="sticky top-0 z-10 flex items-center gap-1.5 bg-surface-3 pb-2">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -908,6 +885,54 @@ export function LaneChangesPanel({
|
||||
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
|
||||
) : mode === "overview" ? (
|
||||
<>
|
||||
{/* Commit and Stage all sit ABOVE the list and stay pinned while it
|
||||
scrolls: on a 60-file change-set the box at the bottom was a
|
||||
scroll away from every file you had just staged. */}
|
||||
{(stagedCount > 0 || toStage.length > 0) && (
|
||||
<div
|
||||
data-testid="lane-changes-actions"
|
||||
className="sticky top-0 z-10 mb-1.5 space-y-1.5 border-b border-border-light bg-surface-3 pb-2"
|
||||
>
|
||||
{stagedCount > 0 && (
|
||||
<div data-testid="lane-changes-commit" className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
|
||||
}}
|
||||
placeholder={t("changes.commitPlaceholder")}
|
||||
aria-label={t("changes.commitPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !message.trim()}
|
||||
onClick={() => void commitStaged()}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("changes.commitStaged", { count: stagedCount })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{toStage.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(
|
||||
"stage",
|
||||
toStage.map((f) => f.path)
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("changes.stageAll", { count: toStage.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ul data-testid="lane-changes-overview" className="space-y-0.5">
|
||||
{files.map((f) => (
|
||||
<ChangeRow
|
||||
@@ -922,31 +947,6 @@ export function LaneChangesPanel({
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{stagedCount > 0 && (
|
||||
<div
|
||||
data-testid="lane-changes-commit"
|
||||
className="mt-2 flex items-center gap-1.5 border-t border-border-light pt-2"
|
||||
>
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
|
||||
}}
|
||||
placeholder={t("changes.commitPlaceholder")}
|
||||
aria-label={t("changes.commitPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !message.trim()}
|
||||
onClick={() => void commitStaged()}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("changes.commitStaged", { count: stagedCount })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : mode === "file" ? (
|
||||
current || (whole && text) ? (
|
||||
|
||||
Reference in New Issue
Block a user