From cb7d81c40485c5f59d49334a44094b3e75ad9db6 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Tue, 5 May 2026 23:18:47 +0530 Subject: [PATCH 1/3] feat(inspector): add repository file browser with Changes/Files tabs Implements a tab-based UI in the Git panel that allows users to browse the complete workspace file tree alongside Git changes. This addresses the workflow gap where users needed to switch to external tools to view files that weren't currently modified. Key features: - Tab switcher in Git section header with "Changes" and "Files" tabs - Files tab displays full repository tree (respects .gitignore patterns) - Shared tree/list view mode between both tabs - Tab preference persisted to localStorage - Files open in read-only mode without Git action buttons - All existing Changes tab functionality preserved Implementation details: - Renamed ChangesSection to GitSection to reflect dual purpose - Added gitActiveTab state with localStorage persistence - Reused existing tree/list rendering components for consistency - Integrated workspaceFilesQueryOptions for data fetching - Tab UI follows existing Setup/Run/Terminal tabs pattern The Files tab queries the existing list_workspace_files backend command, which already implements proper .gitignore filtering and performs well on large repositories. Closes #390 --- src/features/inspector/hooks/use-inspector.ts | 31 +- src/features/inspector/index.tsx | 10 +- src/features/inspector/layout.tsx | 16 + .../inspector/sections/git-section-header.tsx | 317 ++++++++++-------- .../sections/{changes.tsx => git-section.tsx} | 179 ++++++---- 5 files changed, 348 insertions(+), 205 deletions(-) rename src/features/inspector/sections/{changes.tsx => git-section.tsx} (90%) diff --git a/src/features/inspector/hooks/use-inspector.ts b/src/features/inspector/hooks/use-inspector.ts index 74d18a4f8..2982ff1f1 100644 --- a/src/features/inspector/hooks/use-inspector.ts +++ b/src/features/inspector/hooks/use-inspector.ts @@ -9,16 +9,21 @@ import { } from "react"; import { loadRepoScripts, type RepoScripts } from "@/lib/api"; import type { InspectorFileItem } from "@/lib/editor-session"; -import { workspaceChangesQueryOptions } from "@/lib/query-client"; +import { + workspaceChangesQueryOptions, + workspaceFilesQueryOptions, +} from "@/lib/query-client"; import { getInitialActionsOpen, getInitialActiveTab, getInitialChangesHeight, + getInitialGitActiveTab, getInitialTabsHeight, getInitialTabsOpen, INSPECTOR_ACTIONS_OPEN_STORAGE_KEY, INSPECTOR_ACTIVE_TAB_STORAGE_KEY, INSPECTOR_CHANGES_HEIGHT_STORAGE_KEY, + INSPECTOR_GIT_ACTIVE_TAB_STORAGE_KEY, INSPECTOR_SECTION_HEADER_HEIGHT, INSPECTOR_TABS_HEIGHT_STORAGE_KEY, INSPECTOR_TABS_OPEN_STORAGE_KEY, @@ -126,6 +131,7 @@ export function useWorkspaceInspectorSidebar({ const [actionsOpen, setActionsOpen] = useState(getInitialActionsOpen); const [tabsOpen, setTabsOpen] = useState(getInitialTabsOpen); const [activeTab, setActiveTab] = useState(getInitialActiveTab); + const [gitActiveTab, setGitActiveTab] = useState(getInitialGitActiveTab); const [containerHeight, setContainerHeight] = useState(0); const [storedChangesBody, setStoredChangesBody] = useState(() => @@ -218,6 +224,20 @@ export function useWorkspaceInspectorSidebar({ } }, [activeTab]); + useEffect(() => { + try { + window.localStorage.setItem( + INSPECTOR_GIT_ACTIVE_TAB_STORAGE_KEY, + gitActiveTab, + ); + } catch (error) { + console.error( + `[helmor] git active tab save failed for "${INSPECTOR_GIT_ACTIVE_TAB_STORAGE_KEY}"`, + error, + ); + } + }, [gitActiveTab]); + useEffect(() => { try { window.localStorage.setItem( @@ -282,6 +302,12 @@ export function useWorkspaceInspectorSidebar({ }); const changes: InspectorFileItem[] = changesQuery.data?.items ?? []; + const filesQuery = useQuery({ + ...workspaceFilesQueryOptions(workspaceRootPath ?? ""), + enabled: !!workspaceRootPath && gitActiveTab === "files", + }); + const allFiles: InspectorFileItem[] = filesQuery.data ?? []; + const prevChangesRef = useRef | null>(null); const prevRootPathRef = useRef(workspaceRootPath); if (prevRootPathRef.current !== workspaceRootPath) { @@ -437,10 +463,12 @@ export function useWorkspaceInspectorSidebar({ actionsOpen, actionsRef, activeTab, + allFiles, changes, changesHeight: changesBody, containerRef, flashingPaths, + gitActiveTab, handleResizeStart, handleToggleActions, handleToggleTabs, @@ -450,6 +478,7 @@ export function useWorkspaceInspectorSidebar({ repoScripts, scriptsLoaded, setActiveTab, + setGitActiveTab, tabsBodyHeight: tabsBody, tabsOpen, tabsWrapperRef, diff --git a/src/features/inspector/index.tsx b/src/features/inspector/index.tsx index b02805f0c..05d003e69 100644 --- a/src/features/inspector/index.tsx +++ b/src/features/inspector/index.tsx @@ -17,7 +17,7 @@ import { useSetupAutoRun } from "./hooks/use-setup-auto-run"; import { HorizontalResizeHandle, InspectorTabsSection } from "./layout"; import type { ScriptStatus } from "./script-store"; import { ActionsSection } from "./sections/actions"; -import { ChangesSection } from "./sections/changes"; +import { GitSection } from "./sections/git-section"; import { OpenDevServerButton, RunTab } from "./sections/run"; import { SetupTab } from "./sections/setup"; import { TerminalInstancePanel } from "./sections/terminal"; @@ -91,10 +91,12 @@ export function WorkspaceInspectorSidebar({ actionsOpen, actionsRef, activeTab, + allFiles, changes, changesHeight, containerRef, flashingPaths, + gitActiveTab, handleResizeStart, handleToggleActions, handleToggleTabs, @@ -104,6 +106,7 @@ export function WorkspaceInspectorSidebar({ repoScripts, scriptsLoaded, setActiveTab, + setGitActiveTab, tabsBodyHeight, tabsOpen, tabsWrapperRef, @@ -381,13 +384,16 @@ export function WorkspaceInspectorSidebar({ isResizing && "select-none", )} > - void; onChangeRequestClick?: () => void; onCommit?: () => void | Promise; onContinueWorkspace?: () => void | Promise; @@ -104,6 +106,8 @@ export function GitSectionHeader({ forgeRemoteState = null, forgeDetection = null, workspaceId = null, + activeTab = "changes", + onTabChange, onChangeRequestClick, onCommit, onContinueWorkspace, @@ -251,7 +255,7 @@ export function GitSectionHeader({ ref={headerRef} className={cn( INSPECTOR_SECTION_HEADER_CLASS, - "relative gap-1.5 overflow-hidden border-b-0 shadow-[inset_0_-1px_0_color-mix(in_oklch,var(--border)_60%,transparent)]", + "relative flex-col gap-2 overflow-hidden border-b-0 shadow-[inset_0_-1px_0_color-mix(in_oklch,var(--border)_60%,transparent)]", "transition-[background-color,border-color,color,box-shadow] duration-300 ease-out", showForgeOnboarding ? null : gitHeaderHighlightClass, className, @@ -269,148 +273,191 @@ export function GitSectionHeader({ }} /> )} -
- {!showChangeRequest ? ( - - Git - - ) : ( - (() => { - const button = ( - - ); - const openLabel = isMergeRequest - ? "Open merge request" - : "Open pull request"; - return ( - - {button} - - {openLabel} - {openChangeRequestShortcut ? ( - - ) : null} - - - ); - })() - )} -
- {showButton && - (showForgeOnboarding ? ( - - ) : ( -
- {showContinue && ( - - )} -
- - - - + + {isMergeRequest ? ( + + ) : ( + + )} + + + {isMergeRequest ? "!" : "#"} + {changeRequest.number} + + - - {commitShortcut ? ( + + ); + const openLabel = isMergeRequest + ? "Open merge request" + : "Open pull request"; + return ( + + {button} - - {getCommitButtonLabel( - commitButtonMode, - "idle", - changeRequestName, - )} - - + {openLabel} + {openChangeRequestShortcut ? ( + + ) : null} - ) : null} - + + ); + })() + )} +
+ {showButton && + (showForgeOnboarding ? ( + + ) : ( +
+ {showContinue && ( + + )} +
+ + + + + + + {commitShortcut ? ( + + + {getCommitButtonLabel( + commitButtonMode, + "idle", + changeRequestName, + )} + + + + ) : null} + +
-
- ))} + ))} + + + {/* Tab row */} + {onTabChange && ( +
+ + +
+ )} ); } diff --git a/src/features/inspector/sections/changes.tsx b/src/features/inspector/sections/git-section.tsx similarity index 90% rename from src/features/inspector/sections/changes.tsx rename to src/features/inspector/sections/git-section.tsx index 3866d5865..614d25108 100644 --- a/src/features/inspector/sections/changes.tsx +++ b/src/features/inspector/sections/git-section.tsx @@ -70,13 +70,16 @@ const STATUS_COLORS: Record = { D: "text-red-500", }; -type ChangesSectionProps = { +type GitSectionProps = { workspaceId: string | null; workspaceRootPath: string | null; workspaceBranch: string | null; workspaceRemoteUrl: string | null; workspaceTargetBranch: string | null; changes: InspectorFileItem[]; + allFiles: InspectorFileItem[]; + gitActiveTab: "changes" | "files"; + onGitTabChange: (tab: "changes" | "files") => void; editorMode: boolean; activeEditorPath?: string | null; onOpenEditorFile: (path: string, options?: DiffOpenOptions) => void; @@ -93,13 +96,16 @@ type ChangesSectionProps = { isResizing?: boolean; }; -export function ChangesSection({ +export function GitSection({ workspaceId, workspaceRootPath, workspaceBranch, workspaceRemoteUrl, workspaceTargetBranch, changes, + allFiles, + gitActiveTab, + onGitTabChange, editorMode, activeEditorPath, onOpenEditorFile, @@ -111,7 +117,7 @@ export function ChangesSection({ forgeIsRefreshing = false, bodyHeight, isResizing, -}: ChangesSectionProps) { +}: GitSectionProps) { const shouldReduceMotion = useReducedMotion(); const panelTransition = { duration: isResizing || shouldReduceMotion ? 0 : TABS_ANIMATION_MS / 1000, @@ -389,6 +395,8 @@ export function ChangesSection({ hasChanges={hasChanges} isRefreshing={isForgeRefreshing} isContinuingWorkspace={isContinuingWorkspace} + activeTab={gitActiveTab} + onTabChange={onGitTabChange} onChangeRequestClick={ changeRequest ? () => void openUrl(changeRequest.url) : undefined } @@ -397,23 +405,73 @@ export function ChangesSection({ /> - {hasUncommittedChanges && ( + {gitActiveTab === "changes" ? ( <> - {stagedChanges.length > 0 && ( - setStagedOpen((current) => !current)} - changes={stagedChanges} - treeView={changesTreeView} - onToggleTreeView={() => setChangesTreeView((v) => !v)} - action="unstage" - onStageAction={unstageFile} - onBatchAction={unstageAll} + {hasUncommittedChanges && ( + <> + {stagedChanges.length > 0 && ( + setStagedOpen((current) => !current)} + changes={stagedChanges} + treeView={changesTreeView} + onToggleTreeView={() => setChangesTreeView((v) => !v)} + action="unstage" + onStageAction={unstageFile} + onBatchAction={unstageAll} + editorMode={editorMode} + activeEditorPath={activeEditorPath} + onOpenEditorFile={onOpenEditorFile} + flashingPaths={flashingPaths} + workspaceBranch={workspaceBranch} + workspaceRemoteUrl={workspaceRemoteUrl} + /> + )} + {unstagedChanges.length > 0 && ( + + } + count={unstagedChanges.length} + open={changesOpen} + onToggle={() => setChangesOpen((current) => !current)} + changes={unstagedChanges} + treeView={changesTreeView} + onToggleTreeView={() => setChangesTreeView((v) => !v)} + action="stage" + onStageAction={stageFile} + onBatchAction={stageAll} + onDiscard={discardFile} + editorMode={editorMode} + activeEditorPath={activeEditorPath} + onOpenEditorFile={onOpenEditorFile} + flashingPaths={flashingPaths} + workspaceBranch={workspaceBranch} + workspaceRemoteUrl={workspaceRemoteUrl} + /> + )} + + )} + + {(committedChanges.length > 0 || branchSwitching) && ( + setBranchDiffOpen((current) => !current)} + changes={committedChanges} + treeView={branchDiffTreeView} + onToggleTreeView={() => setBranchDiffTreeView((v) => !v)} editorMode={editorMode} activeEditorPath={activeEditorPath} onOpenEditorFile={onOpenEditorFile} @@ -422,60 +480,47 @@ export function ChangesSection({ workspaceRemoteUrl={workspaceRemoteUrl} /> )} - {unstagedChanges.length > 0 && ( - + No changes on this branch yet. + + )} + + ) : ( + <> + {/* Files Tab Content */} + {allFiles.length === 0 ? ( +
+ No files in workspace. +
+ ) : ( +
+ {changesTreeView ? ( + - } - count={unstagedChanges.length} - open={changesOpen} - onToggle={() => setChangesOpen((current) => !current)} - changes={unstagedChanges} - treeView={changesTreeView} - onToggleTreeView={() => setChangesTreeView((v) => !v)} - action="stage" - onStageAction={stageFile} - onBatchAction={stageAll} - onDiscard={discardFile} - editorMode={editorMode} - activeEditorPath={activeEditorPath} - onOpenEditorFile={onOpenEditorFile} - flashingPaths={flashingPaths} - workspaceBranch={workspaceBranch} - workspaceRemoteUrl={workspaceRemoteUrl} - /> + ) : ( + + )} +
)} )} - - {(committedChanges.length > 0 || branchSwitching) && ( - setBranchDiffOpen((current) => !current)} - changes={committedChanges} - treeView={branchDiffTreeView} - onToggleTreeView={() => setBranchDiffTreeView((v) => !v)} - editorMode={editorMode} - activeEditorPath={activeEditorPath} - onOpenEditorFile={onOpenEditorFile} - flashingPaths={flashingPaths} - workspaceBranch={workspaceBranch} - workspaceRemoteUrl={workspaceRemoteUrl} - /> - )} - - {!hasChanges && ( -
- No changes on this branch yet. -
- )}
); From f6e1efff96fd7d642aa4a57dbe39fb1e341d792f Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Wed, 6 May 2026 11:45:40 +0530 Subject: [PATCH 2/3] test: update App.test.tsx to use "Git panel body" aria-label The git section was renamed from ChangesSection to GitSection to reflect its dual purpose (Changes and Files tabs). Update test expectations to match the new aria-label "Git panel body" instead of "Changes panel body". --- src/App.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 31a8ec714..bcacc8024 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -84,7 +84,7 @@ describe("App", () => { screen.getByLabelText("Inspector section Actions"), ).toBeInTheDocument(); expect(screen.getByLabelText("Inspector section Tabs")).toBeInTheDocument(); - expect(screen.getByLabelText("Changes panel body")).toBeInTheDocument(); + expect(screen.getByLabelText("Git panel body")).toBeInTheDocument(); expect(screen.getByLabelText("Actions panel body")).toBeInTheDocument(); // Inspector tabs section starts collapsed; body only mounts when opened. expect( @@ -125,7 +125,7 @@ describe("App", () => { await screen.findByRole("main", { name: "Application shell" }); // Default: tabs section collapsed; changes + actions bodies present. - expect(screen.getByLabelText("Changes panel body")).toBeInTheDocument(); + expect(screen.getByLabelText("Git panel body")).toBeInTheDocument(); expect(screen.getByLabelText("Actions panel body")).toBeInTheDocument(); expect( screen.queryByLabelText("Inspector tabs body"), @@ -134,14 +134,14 @@ describe("App", () => { // Clicking the toggle expands the tabs body. await user.click(screen.getByLabelText("Toggle inspector tabs section")); - expect(screen.getByLabelText("Changes panel body")).toBeInTheDocument(); + expect(screen.getByLabelText("Git panel body")).toBeInTheDocument(); expect(screen.getByLabelText("Actions panel body")).toBeInTheDocument(); expect(screen.getByLabelText("Inspector tabs body")).toBeInTheDocument(); // Clicking again collapses it back. await user.click(screen.getByLabelText("Toggle inspector tabs section")); - expect(screen.getByLabelText("Changes panel body")).toBeInTheDocument(); + expect(screen.getByLabelText("Git panel body")).toBeInTheDocument(); expect(screen.getByLabelText("Actions panel body")).toBeInTheDocument(); expect( screen.queryByLabelText("Inspector tabs body"), From ef3ae4e3d13e2308bbe7d298d81c036bb9c31c83 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Wed, 6 May 2026 11:45:55 +0530 Subject: [PATCH 3/3] chore: add changeset for repository file browser feature --- .changeset/repository-file-browser.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/repository-file-browser.md diff --git a/.changeset/repository-file-browser.md b/.changeset/repository-file-browser.md new file mode 100644 index 000000000..a0a81d5b4 --- /dev/null +++ b/.changeset/repository-file-browser.md @@ -0,0 +1,11 @@ +--- +"helmor": minor +--- + +Add repository file browser to Git panel with tab-based navigation. Users can now browse the full workspace file tree alongside Git changes, reducing context switching during development. + +- Tab-based UI with "Changes" and "Files" tabs in Git panel header +- Files tab displays complete repository tree (respects .gitignore) +- Shared tree/list view toggle between both tabs +- Tab preference persisted to localStorage +- Files open in read-only mode for clean browsing experience