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 diff --git a/src/App.test.tsx b/src/App.test.tsx index e97a976a4..62b63b35b 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"), 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. -
- )}
);