feat(workspace): the Changes panel's repo sections are an accordion
With several repos stacked in the Changes panel, only one section is open at a time: the first starts open, opening another folds the rest so it gets the whole height, and clicking the open one folds it. A chat's open-file request opens the section holding the file. Folded sections stay mounted (CSS), so an unsaved edit survives.
This commit is contained in:
@@ -1002,8 +1002,11 @@ export function Workspace() {
|
||||
const showRoot = !inner || inner.root || repos.length === 0;
|
||||
// A file the chat asks for lies in the inner repo whose path prefixes it.
|
||||
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
|
||||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||
// The stacked sections are an accordion: ONE is open at a time ("" = the
|
||||
// workspace folder, `repo:<path>` an inner repo, else the folder's id).
|
||||
// `undefined` = the first section (also when the open one is gone); `null`
|
||||
// = every section folded.
|
||||
const [openSource, setOpenSource] = useState<string | null | undefined>(undefined);
|
||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||
// workspace (the folder stays on disk). The workspace folder never closes.
|
||||
const sourceCtx = useContextMenu<string>();
|
||||
@@ -1062,11 +1065,7 @@ export function Workspace() {
|
||||
// Its path is the workspace folder's: unfold that section (or the
|
||||
// inner repo's that holds it).
|
||||
const repo = repoOfRef.current(path);
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(repo ? `repo:${repo.path}` : "");
|
||||
return next;
|
||||
});
|
||||
setOpenSource(repo ? `repo:${repo.path}` : "");
|
||||
setChangesOpen(true);
|
||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
@@ -1792,10 +1791,17 @@ export function Workspace() {
|
||||
// One repo only: the one panel, as it always was.
|
||||
if (sections.length === 1) return panel;
|
||||
// Else every repo is a section, stacked like VS Code's
|
||||
// Source Control; the open ones share the height. A
|
||||
// Source Control — an accordion: opening one folds the
|
||||
// others, so the open one has the whole height. A
|
||||
// folded one stays mounted (CSS), so an unsaved edit in
|
||||
// it survives.
|
||||
const folded = foldedSources.has(key);
|
||||
const openKey =
|
||||
openSource === null
|
||||
? null
|
||||
: sections.some((x) => x.key === openSource)
|
||||
? openSource
|
||||
: sections[0]!.key;
|
||||
const folded = key !== openKey;
|
||||
return (
|
||||
<section
|
||||
key={key}
|
||||
@@ -1811,13 +1817,7 @@ export function Workspace() {
|
||||
type="button"
|
||||
aria-expanded={!folded}
|
||||
onContextMenu={(e) => sourceCtx.open(e, key)}
|
||||
onClick={() =>
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onClick={() => setOpenSource(folded ? key : null)}
|
||||
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
{folded ? (
|
||||
|
||||
@@ -288,11 +288,19 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith(1);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||
// A folded section keeps its panel mounted, only hidden.
|
||||
// An accordion: the first section starts open, the others folded.
|
||||
const toggle = sections[0]!.querySelector("button")!;
|
||||
fireEvent.click(toggle);
|
||||
const shop = sections[1]!.querySelector("button")!;
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(shop).toHaveAttribute("aria-expanded", "false");
|
||||
// Opening one folds the other; a folded section keeps its panel mounted.
|
||||
fireEvent.click(shop);
|
||||
expect(shop).toHaveAttribute("aria-expanded", "true");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
|
||||
// Clicking the open one folds it too.
|
||||
fireEvent.click(shop);
|
||||
expect(shop).toHaveAttribute("aria-expanded", "false");
|
||||
// A chat's file is the workspace folder's: its section unfolds.
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||
@@ -390,13 +398,13 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
|
||||
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
|
||||
// A chat's file inside an inner repo opens in that repo's section.
|
||||
fireEvent.click(sections[1]!.querySelector("button")!);
|
||||
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||
act(() => {
|
||||
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(sections[0]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(
|
||||
{ lane: 2, repo: "apps/web" },
|
||||
"src/x.ts",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user