Skip to content

Commit 9980ac0

Browse files
authored
Add unified workspace panel to right sidebar (#343)
- Replace legacy files/editor tabs with a single workspace view - Add responsive stacked/split workspace layout for tree and editor - Normalize persisted right-panel tabs and update related labels
1 parent 10d9395 commit 9980ac0

11 files changed

Lines changed: 162 additions & 39 deletions

apps/web/src/components/CodeViewerPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,7 @@ export default function CodeViewerPanel() {
712712
{!activeTab ? (
713713
<div className="flex h-full flex-col items-center justify-center gap-2 px-5 text-center text-muted-foreground/60">
714714
<FileCodeIcon className="size-8 opacity-40" />
715-
<p className="text-xs">Click a file in the sidebar to view it here.</p>
715+
<p className="text-xs">Click a file to view it here.</p>
716716
</div>
717717
) : (
718718
<CodeViewerFileContent

apps/web/src/components/RightPanelHeader.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CodeIcon, FolderIcon, GitCompareIcon, PanelRightCloseIcon } from "lucide-react";
1+
import { FolderIcon, GitCompareIcon, PanelRightCloseIcon } from "lucide-react";
22
import { memo } from "react";
33
import { isElectron } from "~/env";
44
import { cn } from "~/lib/utils";
@@ -10,8 +10,7 @@ const TABS: readonly {
1010
label: string;
1111
icon: typeof FolderIcon;
1212
}[] = [
13-
{ id: "files", label: "Files", icon: FolderIcon },
14-
{ id: "editor", label: "Editor", icon: CodeIcon },
13+
{ id: "workspace", label: "Workspace", icon: FolderIcon },
1514
{ id: "diffs", label: "Diffs", icon: GitCompareIcon },
1615
];
1716

apps/web/src/components/Sidebar.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import { resolve } from "node:path";
33
import { describe, expect, it } from "vitest";
44

55
describe("Sidebar file tree shortcut", () => {
6-
it("opens the right-panel file tree instead of mounting the tree inline", () => {
6+
it("opens the right-panel workspace instead of mounting the tree inline", () => {
77
const src = readFileSync(resolve(import.meta.dirname, "./Sidebar.tsx"), "utf8");
88

9-
expect(src).toContain('aria-label="Open file tree"');
10-
expect(src).toContain('useRightPanelStore.getState().open("files")');
9+
expect(src).toContain('aria-label="Open workspace"');
10+
expect(src).toContain('useRightPanelStore.getState().open("workspace")');
1111
expect(src).not.toContain("<WorkspaceFileTree");
1212
});
1313
});

apps/web/src/components/Sidebar.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,17 +1852,17 @@ export default function Sidebar() {
18521852
render={
18531853
<button
18541854
type="button"
1855-
aria-label="Open file tree"
1855+
aria-label="Open workspace"
18561856
className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
18571857
onClick={() => {
1858-
useRightPanelStore.getState().open("files");
1858+
useRightPanelStore.getState().open("workspace");
18591859
}}
18601860
/>
18611861
}
18621862
>
18631863
<FolderIcon className="size-3.5" />
18641864
</TooltipTrigger>
1865-
<TooltipPopup side="top">Open file tree</TooltipPopup>
1865+
<TooltipPopup side="top">Open workspace</TooltipPopup>
18661866
</Tooltip>
18671867
<ProjectSortMenu
18681868
projectSortOrder={appSettings.sidebarProjectSortOrder}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveWorkspaceLayoutMode } from "./WorkspacePanel";
3+
4+
describe("resolveWorkspaceLayoutMode", () => {
5+
it("stacks the tree above the editor in narrower panels", () => {
6+
expect(resolveWorkspaceLayoutMode(320)).toBe("stacked");
7+
expect(resolveWorkspaceLayoutMode(599)).toBe("stacked");
8+
});
9+
10+
it("splits the tree and editor side by side once enough width is available", () => {
11+
expect(resolveWorkspaceLayoutMode(600)).toBe("split");
12+
expect(resolveWorkspaceLayoutMode(960)).toBe("split");
13+
});
14+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { FolderIcon } from "lucide-react";
2+
import { memo, type ReactNode, useEffect, useRef, useState } from "react";
3+
import { cn } from "~/lib/utils";
4+
5+
export type WorkspaceLayoutMode = "stacked" | "split";
6+
7+
const WORKSPACE_SPLIT_MIN_WIDTH_PX = 600;
8+
9+
export function resolveWorkspaceLayoutMode(width: number): WorkspaceLayoutMode {
10+
return width >= WORKSPACE_SPLIT_MIN_WIDTH_PX ? "split" : "stacked";
11+
}
12+
13+
function basenameOfPath(pathValue: string): string {
14+
const normalizedPath = pathValue.replace(/\/+$/, "");
15+
const segments = normalizedPath.split("/");
16+
return segments[segments.length - 1] || pathValue;
17+
}
18+
19+
export const WorkspacePanel = memo(function WorkspacePanel(props: {
20+
cwd: string | null;
21+
tree: ReactNode;
22+
editor: ReactNode;
23+
}) {
24+
const containerRef = useRef<HTMLDivElement | null>(null);
25+
const [layoutMode, setLayoutMode] = useState<WorkspaceLayoutMode>("stacked");
26+
const workspaceLabel = props.cwd ? basenameOfPath(props.cwd) : "Workspace";
27+
28+
useEffect(() => {
29+
const element = containerRef.current;
30+
if (!element) {
31+
return;
32+
}
33+
34+
const syncLayoutMode = () => {
35+
const nextMode = resolveWorkspaceLayoutMode(element.getBoundingClientRect().width);
36+
setLayoutMode((currentMode) => (currentMode === nextMode ? currentMode : nextMode));
37+
};
38+
39+
syncLayoutMode();
40+
41+
if (typeof ResizeObserver === "undefined") {
42+
return;
43+
}
44+
45+
const observer = new ResizeObserver(() => {
46+
syncLayoutMode();
47+
});
48+
observer.observe(element);
49+
50+
return () => {
51+
observer.disconnect();
52+
};
53+
}, []);
54+
55+
return (
56+
<div ref={containerRef} className="flex h-full min-h-0 flex-col bg-background">
57+
<div className={cn("flex min-h-0 flex-1", layoutMode === "split" ? "flex-row" : "flex-col")}>
58+
{props.cwd ? (
59+
<section
60+
className={cn(
61+
"flex min-h-0 shrink-0 overflow-hidden bg-card/35",
62+
layoutMode === "split"
63+
? "w-[clamp(15rem,32%,19rem)] border-r border-border/60"
64+
: "h-[clamp(14rem,34vh,20rem)] border-b border-border/60",
65+
)}
66+
>
67+
<div className="flex min-h-0 flex-1 flex-col">
68+
<div className="border-b border-border/60 px-3 py-2">
69+
<div className="flex items-center gap-2">
70+
<FolderIcon className="size-3.5 shrink-0 text-muted-foreground/70" />
71+
<div className="min-w-0">
72+
<p className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground/55">
73+
Workspace
74+
</p>
75+
<p className="truncate font-mono text-[11px] text-foreground/85">
76+
{workspaceLabel}
77+
</p>
78+
</div>
79+
</div>
80+
</div>
81+
<div className="min-h-0 flex-1 overflow-y-auto py-2">{props.tree}</div>
82+
</div>
83+
</section>
84+
) : null}
85+
<section className="min-h-0 min-w-0 flex-1 bg-background">{props.editor}</section>
86+
</div>
87+
</div>
88+
);
89+
});

apps/web/src/components/file-view/FileViewShell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function FileViewShell(props: { initialCwd: string; initialPath: string |
114114
{!activeTab ? (
115115
<div className="flex h-full flex-col items-center justify-center gap-2 px-5 text-center text-muted-foreground/60">
116116
<FileCodeIcon className="size-8 opacity-40" />
117-
<p className="text-xs">Click a file in the sidebar to view it here.</p>
117+
<p className="text-xs">Click a file to view it here.</p>
118118
</div>
119119
) : (
120120
<CodeViewerFileContent

apps/web/src/hooks/useFileViewNavigation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useCodeViewerStore } from "~/codeViewerStore";
44
import { useStore } from "~/store";
55

66
/**
7-
* Opens a file in the code-viewer side panel of the active thread.
7+
* Opens a file in the workspace side panel of the active thread.
88
* If the caller is not on a thread page, navigates to the most recent thread first.
99
*/
1010
export function useFileViewNavigation() {
@@ -20,7 +20,7 @@ export function useFileViewNavigation() {
2020
(cwd: string, relativePath: string) => {
2121
openFile(cwd, relativePath);
2222
// If not already on a thread page, navigate to the most recent thread
23-
// so the code-viewer inline sidebar is visible.
23+
// so the workspace inline sidebar is visible.
2424
if (!threadId) {
2525
const sorted = threads.toSorted((a, b) =>
2626
(b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt),
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from "vitest";
2+
import { normalizeRightPanelTab } from "./rightPanelStore";
3+
4+
describe("normalizeRightPanelTab", () => {
5+
it("maps legacy files and editor tabs into the workspace tab", () => {
6+
expect(normalizeRightPanelTab("files")).toBe("workspace");
7+
expect(normalizeRightPanelTab("editor")).toBe("workspace");
8+
});
9+
10+
it("preserves supported tabs and rejects invalid values", () => {
11+
expect(normalizeRightPanelTab("workspace")).toBe("workspace");
12+
expect(normalizeRightPanelTab("diffs")).toBe("diffs");
13+
expect(normalizeRightPanelTab("unknown")).toBeNull();
14+
expect(normalizeRightPanelTab(null)).toBeNull();
15+
});
16+
});

apps/web/src/rightPanelStore.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { create } from "zustand";
22

3-
export type RightPanelTab = "files" | "editor" | "diffs";
3+
export type RightPanelTab = "workspace" | "diffs";
44

55
interface RightPanelState {
66
isOpen: boolean;
@@ -12,19 +12,30 @@ interface RightPanelState {
1212

1313
const STORAGE_KEY = "okcode:right-panel-tab:v1";
1414

15-
const VALID_TABS: readonly RightPanelTab[] = ["files", "editor", "diffs"];
15+
const VALID_TABS: readonly RightPanelTab[] = ["workspace", "diffs"];
16+
17+
export function normalizeRightPanelTab(value: string | null | undefined): RightPanelTab | null {
18+
if (value === "files" || value === "editor") {
19+
return "workspace";
20+
}
21+
if ((VALID_TABS as readonly string[]).includes(value ?? "")) {
22+
return value as RightPanelTab;
23+
}
24+
return null;
25+
}
1626

1727
function readPersistedTab(): RightPanelTab {
18-
if (typeof window === "undefined") return "files";
28+
if (typeof window === "undefined") return "workspace";
1929
try {
2030
const raw = window.localStorage.getItem(STORAGE_KEY);
21-
if (raw && (VALID_TABS as readonly string[]).includes(raw)) {
22-
return raw as RightPanelTab;
31+
const normalized = normalizeRightPanelTab(raw);
32+
if (normalized) {
33+
return normalized;
2334
}
2435
} catch {
2536
// ignore storage errors
2637
}
27-
return "files";
38+
return "workspace";
2839
}
2940

3041
function persistTab(tab: RightPanelTab): void {

0 commit comments

Comments
 (0)