From 3b3a393cc8d0cab0ee69ffc0f02e3aaa47da109d Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 3 Jun 2025 10:39:40 -0700 Subject: [PATCH 01/18] wip --- packages/web/package.json | 1 + .../[...path]/components/codePreviewPanel.tsx | 2 +- .../app/[domain]/browse/[...path]/page.tsx | 56 ++++++++++++-- packages/web/src/features/fileTree/actions.ts | 76 +++++++++++++++++++ yarn.lock | 1 + 5 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 packages/web/src/features/fileTree/actions.ts diff --git a/packages/web/package.json b/packages/web/package.json index 4ce595500..38dec8b58 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -139,6 +139,7 @@ "react-resizable-panels": "^2.1.1", "server-only": "^0.0.1", "sharp": "^0.33.5", + "simple-git": "^3.27.0", "strip-json-comments": "^5.0.1", "stripe": "^17.6.0", "tailwind-merge": "^2.5.2", diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx index 14726b473..d118bd818 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx @@ -187,7 +187,7 @@ export const CodePreviewPanel = ({ return ( diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 12a290a77..695497a54 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -2,7 +2,7 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; import { TopBar } from "@/app/[domain]/components/topBar"; import { Separator } from '@/components/ui/separator'; import { getFileSource } from '@/features/search/fileSourceApi'; -import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"; +import { cn, getCodeHostInfoForRepo, isServiceError, measure } from "@/lib/utils"; import { base64Decode } from "@/lib/utils"; import { ErrorCode } from "@/lib/errorCodes"; import { LuFileX2, LuBookX } from "react-icons/lu"; @@ -11,6 +11,10 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { getRepoInfoByName } from "@/actions"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import Image from "next/image"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { getTree } from "@/features/fileTree/actions"; +import { ScrollArea } from "@/components/ui/scroll-area"; interface BrowsePageProps { params: { @@ -139,13 +143,49 @@ export default async function BrowsePage({ - + + + + + ) } + +const FileTreePanel = async ({ repoName, revisionName, path, domain }: { repoName: string, revisionName: string, path: string, domain: string }) => { + + const { data: response } = await measure(() => getTree(repoName, revisionName, path, domain), 'getTree'); + if (isServiceError(response)) { + return "error"; + } + + return ( + + + {response.tree + .filter(item => item.type === 'tree') + .filter(item => path.startsWith(item.path)) + .map(item => ( +
+ {item.type} - {item.path} +
+ ))} +
+
+ ) +} diff --git a/packages/web/src/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts new file mode 100644 index 000000000..f96108317 --- /dev/null +++ b/packages/web/src/features/fileTree/actions.ts @@ -0,0 +1,76 @@ +'use server'; + +import { sew, withAuth, withOrgMembership } from '@/actions'; +import { env } from '@/env.mjs'; +import { OrgRole, Repo } from '@sourcebot/db'; +import { prisma } from '@/prisma'; +import { notFound } from '@/lib/serviceError'; +import { simpleGit } from 'simple-git'; +import path from 'path'; + + +export const getTree = async (repoName: string, revisionName: string, filePath: string, domain: string) => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async ({ org }) => { + + const repo = await prisma.repo.findFirst({ + where: { + name: repoName, + orgId: org.id, + }, + }); + + if (!repo) { + return notFound(); + } + + const { path: repoPath } = getRepoPath(repo); + + const git = simpleGit().cwd(repoPath); + + const parentDir = path.dirname(filePath); + console.log(parentDir); + + const result = await git.raw([ + 'ls-tree', + 'HEAD', + '-r', + '-t', + '--format=%(objecttype),%(path)' + ]); + + const lines = result.split('\n').filter(line => line.trim()); + + const tree = lines.map(line => { + const [type, path] = line.split(','); + return { + type, + path, + } + }); + + return { + tree, + } + + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) +); + +const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } => { + // If we are dealing with a local repository, then use that as the path. + // Mark as read-only since we aren't guaranteed to have write access to the local filesystem. + const cloneUrl = new URL(repo.cloneUrl); + if (repo.external_codeHostType === 'generic-git-host' && cloneUrl.protocol === 'file:') { + return { + path: cloneUrl.pathname, + isReadOnly: true, + } + } + + const reposPath = path.join(env.DATA_CACHE_DIR, 'repos'); + + return { + path: path.join(reposPath, repo.id.toString()), + isReadOnly: false, + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 76773d746..0840cae25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6033,6 +6033,7 @@ __metadata: react-resizable-panels: "npm:^2.1.1" server-only: "npm:^0.0.1" sharp: "npm:^0.33.5" + simple-git: "npm:^3.27.0" strip-json-comments: "npm:^5.0.1" stripe: "npm:^17.6.0" tailwind-merge: "npm:^2.5.2" From b5ff7554b7280e064d976c8c8a6ce1a5e728c94c Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 3 Jun 2025 12:09:29 -0700 Subject: [PATCH 02/18] tree rendering --- .../app/[domain]/browse/[...path]/page.tsx | 38 ++++++++--- packages/web/src/features/fileTree/actions.ts | 68 +++++++++++++++++-- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 695497a54..fbb57e6e6 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -13,7 +13,7 @@ import { CodePreviewPanel } from "./components/codePreviewPanel"; import Image from "next/image"; import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; -import { getTree } from "@/features/fileTree/actions"; +import { FileTreeNode, getTree } from "@/features/fileTree/actions"; import { ScrollArea } from "@/components/ui/scroll-area"; interface BrowsePageProps { @@ -166,25 +166,41 @@ export default async function BrowsePage({ } const FileTreePanel = async ({ repoName, revisionName, path, domain }: { repoName: string, revisionName: string, path: string, domain: string }) => { - - const { data: response } = await measure(() => getTree(repoName, revisionName, path, domain), 'getTree'); + const { data: response } = await measure(() => getTree(repoName, revisionName, domain), 'getTree'); if (isServiceError(response)) { return "error"; } + function renderTree(nodes: FileTreeNode, parentPath = "") { + return ( +
    + {nodes.children.map((node) => { + const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name; + if (node.type === 'tree') { + return ( +
  • + {node.name}/ + {node.children.length > 0 && renderTree(node, fullPath)} +
  • + ); + } else { + return ( +
  • + {node.name} +
  • + ); + } + })} +
+ ); + } + return ( - {response.tree - .filter(item => item.type === 'tree') - .filter(item => path.startsWith(item.path)) - .map(item => ( -
- {item.type} - {item.path} -
- ))} + {renderTree(response.tree)}
) diff --git a/packages/web/src/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts index f96108317..038d4051f 100644 --- a/packages/web/src/features/fileTree/actions.ts +++ b/packages/web/src/features/fileTree/actions.ts @@ -8,8 +8,13 @@ import { notFound } from '@/lib/serviceError'; import { simpleGit } from 'simple-git'; import path from 'path'; +export type FileTreeNode = { + name: string; + type: string; + children: FileTreeNode[]; +} -export const getTree = async (repoName: string, revisionName: string, filePath: string, domain: string) => sew(() => +export const getTree = async (repoName: string, revisionName: string, domain: string) => sew(() => withAuth((session) => withOrgMembership(session, domain, async ({ org }) => { @@ -28,12 +33,9 @@ export const getTree = async (repoName: string, revisionName: string, filePath: const git = simpleGit().cwd(repoPath); - const parentDir = path.dirname(filePath); - console.log(parentDir); - const result = await git.raw([ 'ls-tree', - 'HEAD', + revisionName, '-r', '-t', '--format=%(objecttype),%(path)' @@ -41,7 +43,7 @@ export const getTree = async (repoName: string, revisionName: string, filePath: const lines = result.split('\n').filter(line => line.trim()); - const tree = lines.map(line => { + const flatList = lines.map(line => { const [type, path] = line.split(','); return { type, @@ -49,6 +51,8 @@ export const getTree = async (repoName: string, revisionName: string, filePath: } }); + const tree = buildFileTree(flatList); + return { tree, } @@ -73,4 +77,56 @@ const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } => { path: path.join(reposPath, repo.id.toString()), isReadOnly: false, } +} + +const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { + const root: FileTreeNode = { + name: 'root', + type: 'tree', + children: [], + }; + + for (const item of flatList) { + const parts = item.path.split('/'); + let current: FileTreeNode = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLeaf = i === parts.length - 1; + const nodeType = isLeaf ? item.type : 'tree'; + let next = current.children.find(child => child.name === part && child.type === nodeType); + + if (!next) { + next = { + name: part, + type: nodeType, + children: [], + }; + current.children.push(next); + } + current = next; + } + } + + const sortTree = (node: FileTreeNode): FileTreeNode => { + if (node.type === 'blob') { + return node; + } + + const sortedChildren = node.children + .map(sortTree) + .sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'tree' ? -1 : 1; + } + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + + return { + ...node, + children: sortedChildren, + }; + }; + + return sortTree(root); } \ No newline at end of file From 0af63696c2a45c082d76cf8ed2e6f88483edbbb4 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 3 Jun 2025 16:15:10 -0700 Subject: [PATCH 03/18] wip --- packages/web/package.json | 1 + .../app/[domain]/browse/[...path]/page.tsx | 65 ++----- packages/web/src/features/fileTree/actions.ts | 3 + .../fileTree/components/fileTreePanel.tsx | 159 ++++++++++++++++++ yarn.lock | 17 ++ 5 files changed, 195 insertions(+), 50 deletions(-) create mode 100644 packages/web/src/features/fileTree/components/fileTreePanel.tsx diff --git a/packages/web/package.json b/packages/web/package.json index 38dec8b58..1189ae3ba 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -145,6 +145,7 @@ "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "usehooks-ts": "^3.1.0", + "vscode-icons-js": "^11.6.1", "zod": "^3.24.3", "zod-to-json-schema": "^3.24.5" }, diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index fbb57e6e6..4b087178b 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -11,10 +11,10 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { getRepoInfoByName } from "@/actions"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import Image from "next/image"; -import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { ResizablePanelGroup } from "@/components/ui/resizable"; import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; -import { FileTreeNode, getTree } from "@/features/fileTree/actions"; -import { ScrollArea } from "@/components/ui/scroll-area"; +import { getTree } from "@/features/fileTree/actions"; +import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel"; interface BrowsePageProps { params: { @@ -106,6 +106,8 @@ export default async function BrowsePage({ webUrl: repoInfo.webUrl, }); + const { data: getTreeResponse } = await measure(() => getTree(repoName, revisionName ?? 'HEAD', params.domain), 'getTree'); + return ( <>
@@ -146,12 +148,16 @@ export default async function BrowsePage({ - + {isServiceError(getTreeResponse) ? ( + Error loading file tree + ) : ( + + )} ) } - -const FileTreePanel = async ({ repoName, revisionName, path, domain }: { repoName: string, revisionName: string, path: string, domain: string }) => { - const { data: response } = await measure(() => getTree(repoName, revisionName, domain), 'getTree'); - if (isServiceError(response)) { - return "error"; - } - - function renderTree(nodes: FileTreeNode, parentPath = "") { - return ( -
    - {nodes.children.map((node) => { - const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name; - if (node.type === 'tree') { - return ( -
  • - {node.name}/ - {node.children.length > 0 && renderTree(node, fullPath)} -
  • - ); - } else { - return ( -
  • - {node.name} -
  • - ); - } - })} -
- ); - } - - return ( - - - {renderTree(response.tree)} - - - ) -} diff --git a/packages/web/src/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts index 038d4051f..6dd6a3521 100644 --- a/packages/web/src/features/fileTree/actions.ts +++ b/packages/web/src/features/fileTree/actions.ts @@ -10,6 +10,7 @@ import path from 'path'; export type FileTreeNode = { name: string; + path: string; type: string; children: FileTreeNode[]; } @@ -82,6 +83,7 @@ const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } => { const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const root: FileTreeNode = { name: 'root', + path: '', type: 'tree', children: [], }; @@ -99,6 +101,7 @@ const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode if (!next) { next = { name: part, + path: item.path, type: nodeType, children: [], }; diff --git a/packages/web/src/features/fileTree/components/fileTreePanel.tsx b/packages/web/src/features/fileTree/components/fileTreePanel.tsx new file mode 100644 index 000000000..e8ecfe071 --- /dev/null +++ b/packages/web/src/features/fileTree/components/fileTreePanel.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { FileTreeNode as RawFileTreeNode } from "../actions"; +import { ResizablePanel } from "@/components/ui/resizable"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getIconForFile, getIconForFolder } from "vscode-icons-js"; +import { Icon } from '@iconify/react'; +import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; +import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; + +interface FileTreePanelProps { + tree: RawFileTreeNode; + path: string; + repoName: string; + revisionName: string; +} + +type FileTreeNode = Omit & { + isCollapsed: boolean; + children: FileTreeNode[]; +} + +const buildCollapsableTree = (tree: RawFileTreeNode): FileTreeNode => { + return { + ...tree, + isCollapsed: true, + children: tree.children.map(buildCollapsableTree), + } +} + +const transformTree = ( + tree: FileTreeNode, + transform: (node: FileTreeNode) => FileTreeNode +): FileTreeNode => { + const newNode = transform(tree); + const newChildren = tree.children.map(child => transformTree(child, transform)); + return { + ...newNode, + children: newChildren, + } +} + +export const FileTreePanel = ({ tree: _tree, path, repoName, revisionName }: FileTreePanelProps) => { + + const [tree, setTree] = useState(buildCollapsableTree(_tree)); + + const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => { + setTree(transformTree(tree, (currentNode) => { + if (currentNode.path === path) { + currentNode.isCollapsed = isCollapsed; + } + return currentNode; + })); + }, [tree]); + + useEffect(() => { + const parts = path.split('/'); + let currentPath = ''; + parts.forEach((part) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + console.log('setting collapse for', currentPath); + setIsCollapsed(currentPath, false); + }); + }, [path]); + + const { navigateToPath } = useBrowseNavigation(); + + const renderTree = useCallback((nodes: FileTreeNode, depth = 0) => { + return ( +
+ {nodes.children.map((node) => { + return ( + <> +
{ + if (node.type === 'tree') { + setIsCollapsed(node.path, !node.isCollapsed); + } + else if (node.type === 'blob') { + navigateToPath({ + repoName: repoName, + revisionName: revisionName, + path: node.path, + pathType: 'blob', + }); + } + }} + > + {}} + /> +
+ {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} + + ); + })} +
+ ); + }, []); + + const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); + + return ( + + + {renderedTree} + + + ) +} + +const FileTreeItem = ({ + node, + onClick, +}: { + node: FileTreeNode, + onClick: () => void +}) => { + const iconName = useMemo(() => { + if (node.type === 'tree') { + const icon = getIconForFolder(node.name); + if (icon) { + const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; + return iconName; + } + } else if (node.type === 'blob') { + const icon = getIconForFile(node.name); + if (icon) { + const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; + return iconName; + } + } + + return "vscode-icons:file-type-unknown"; + }, [node.type]); + + return ( +
+
+ {node.type === 'tree' && ( + node.isCollapsed ? ( + + ) : ( + + ) + )} +
+ + {node.name} +
+ ) +} diff --git a/yarn.lock b/yarn.lock index 0840cae25..2f7eb2054 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6044,6 +6044,7 @@ __metadata: usehooks-ts: "npm:^3.1.0" vite-tsconfig-paths: "npm:^5.1.3" vitest: "npm:^2.1.5" + vscode-icons-js: "npm:^11.6.1" zod: "npm:^3.24.3" zod-to-json-schema: "npm:^3.24.5" languageName: unknown @@ -6332,6 +6333,13 @@ __metadata: languageName: node linkType: hard +"@types/jasmine@npm:^3.6.3": + version: 3.10.18 + resolution: "@types/jasmine@npm:3.10.18" + checksum: 10c0/09914c65b09cb90536debd21c90000b27a26b730881d45e32c5c19cd429cdcf7407cf27dad2b11a5319bbba7e7e6d756b4a6f1f3c854b691bd1f0ac4f4ea1226 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -16321,6 +16329,15 @@ __metadata: languageName: node linkType: hard +"vscode-icons-js@npm:^11.6.1": + version: 11.6.1 + resolution: "vscode-icons-js@npm:11.6.1" + dependencies: + "@types/jasmine": "npm:^3.6.3" + checksum: 10c0/bae04f20e3a981cd730f92fed29ff07f389837a12f22c7c36656f7ed005bd672d19dd2213c3db16f8dbbf9c4e0655ccb303a847616fbdb290071c7ec4dc99b99 + languageName: node + linkType: hard + "vscode-languageserver-types@npm:^3.17.1": version: 3.17.5 resolution: "vscode-languageserver-types@npm:3.17.5" From fc26a476e98f0498ec11b20b35849750452695a4 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 3 Jun 2025 17:52:58 -0700 Subject: [PATCH 04/18] savepoint: refactored a bunch of stuff to allow us to persist the file hierchy across file navigations --- .../[...path]/components/codePreviewPanel.tsx | 66 +++++----- .../app/[domain]/browse/[...path]/page.tsx | 115 ++++++++---------- .../[domain]/browse/browseStateProvider.tsx | 22 +++- .../web/src/app/[domain]/browse/layout.tsx | 69 ++++++++++- .../fileTree/components/fileTreePanel.tsx | 67 ++++++---- 5 files changed, 203 insertions(+), 136 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx index d118bd818..25489588c 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx @@ -1,6 +1,5 @@ 'use client'; -import { ResizablePanel } from "@/components/ui/resizable"; import { ScrollArea } from "@/components/ui/scroll-area"; import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup"; import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension"; @@ -186,41 +185,36 @@ export const CodePreviewPanel = ({ const theme = useCodeMirrorTheme(); return ( - - - - {editorRef && editorRef.view && currentSelection && ( - - )} - {editorRef && hasCodeNavEntitlement && ( - - )} - - - - + + + {editorRef && editorRef.view && currentSelection && ( + + )} + {editorRef && hasCodeNavEntitlement && ( + + )} + + + ) } diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 4b087178b..ca0950b82 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -1,8 +1,7 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; -import { TopBar } from "@/app/[domain]/components/topBar"; import { Separator } from '@/components/ui/separator'; import { getFileSource } from '@/features/search/fileSourceApi'; -import { cn, getCodeHostInfoForRepo, isServiceError, measure } from "@/lib/utils"; +import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"; import { base64Decode } from "@/lib/utils"; import { ErrorCode } from "@/lib/errorCodes"; import { LuFileX2, LuBookX } from "react-icons/lu"; @@ -11,10 +10,6 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { getRepoInfoByName } from "@/actions"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import Image from "next/image"; -import { ResizablePanelGroup } from "@/components/ui/resizable"; -import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; -import { getTree } from "@/features/fileTree/actions"; -import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel"; interface BrowsePageProps { params: { @@ -106,67 +101,53 @@ export default async function BrowsePage({ webUrl: repoInfo.webUrl, }); - const { data: getTreeResponse } = await measure(() => getTree(repoName, revisionName ?? 'HEAD', params.domain), 'getTree'); - return ( - <> -
- - -
- - {(fileSourceResponse.webUrl && codeHostInfo) && ( - - {codeHostInfo.codeHostName} - Open in {codeHostInfo.codeHostName} - - )} -
- -
- - {isServiceError(getTreeResponse) ? ( - Error loading file tree - ) : ( - - )} - - - - + ) + + // return ( + //
+ //
+ // + // {(fileSourceResponse.webUrl && codeHostInfo) && ( + // + // {codeHostInfo.codeHostName} + // Open in {codeHostInfo.codeHostName} + // + // )} + //
+ // + // + //
+ // ) } diff --git a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx index 9a4bb4b3e..0b6e75f30 100644 --- a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx +++ b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx @@ -2,7 +2,6 @@ import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { createContext, useCallback, useEffect, useState } from "react"; -import { BOTTOM_PANEL_MIN_SIZE } from "./components/bottomPanel"; export interface BrowseState { selectedSymbolInfo?: { @@ -14,13 +13,17 @@ export interface BrowseState { isBottomPanelCollapsed: boolean; activeExploreMenuTab: "references" | "definitions"; bottomPanelSize: number; + repoName: string; + revisionName: string; } const defaultState: BrowseState = { selectedSymbolInfo: undefined, isBottomPanelCollapsed: true, activeExploreMenuTab: "references", - bottomPanelSize: BOTTOM_PANEL_MIN_SIZE, + bottomPanelSize: 35, + repoName: '', + revisionName: '', }; export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState"; @@ -33,8 +36,19 @@ export const BrowseStateContext = createContext<{ updateBrowseState: () => {}, }); -export const BrowseStateProvider = ({ children }: { children: React.ReactNode }) => { - const [state, setState] = useState(defaultState); +interface BrowseStateProviderProps { + children: React.ReactNode; + repoName: string; + revisionName: string; +} + +export const BrowseStateProvider = ({ children, repoName, revisionName }: BrowseStateProviderProps) => { + const [state, setState] = useState({ + ...defaultState, + repoName, + revisionName, + }); + const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM); const onUpdateState = useCallback((state: Partial) => { diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx index 4f23d9cc1..82423b303 100644 --- a/packages/web/src/app/[domain]/browse/layout.tsx +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -1,22 +1,83 @@ -import { ResizablePanelGroup } from "@/components/ui/resizable"; +'use client'; + +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { BottomPanel } from "./components/bottomPanel"; import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; import { BrowseStateProvider } from "./browseStateProvider"; +import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel"; +import { TopBar } from "@/app/[domain]/components/topBar"; +import { Separator } from '@/components/ui/separator'; +import { notFound, usePathname } from "next/navigation"; interface LayoutProps { children: React.ReactNode; + params: { + domain: string; + } } export default function Layout({ - children, + children: codePreviewPanel, + params, }: LayoutProps) { + const pathname = usePathname(); + + const startIndex = pathname.indexOf('/browse/'); + if (startIndex === -1) { + return notFound(); + } + + const rawPath = pathname.substring(startIndex + '/browse/'.length); + const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//); + if (sentinalIndex === -1) { + return notFound(); + } + + const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@'); + const repoName = repoAndRevisionName[0]; + const revisionName = repoAndRevisionName.length > 1 ? repoAndRevisionName[1] : undefined; + return ( - +
+
+ + +
- {children} + + + + + + + + {codePreviewPanel} + + + diff --git a/packages/web/src/features/fileTree/components/fileTreePanel.tsx b/packages/web/src/features/fileTree/components/fileTreePanel.tsx index e8ecfe071..0b1e01d11 100644 --- a/packages/web/src/features/fileTree/components/fileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/fileTreePanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FileTreeNode as RawFileTreeNode } from "../actions"; +import { getTree, FileTreeNode as RawFileTreeNode } from "../actions"; import { ResizablePanel } from "@/components/ui/resizable"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -8,19 +8,44 @@ import { getIconForFile, getIconForFolder } from "vscode-icons-js"; import { Icon } from '@iconify/react'; import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState"; +import { useQuery } from "@tanstack/react-query"; +import { unwrapServiceError } from "@/lib/utils"; +import { useDomain } from "@/hooks/useDomain"; + -interface FileTreePanelProps { - tree: RawFileTreeNode; - path: string; - repoName: string; - revisionName: string; -} type FileTreeNode = Omit & { isCollapsed: boolean; children: FileTreeNode[]; } +export const FileTreePanel = () => { + const { state: { repoName, revisionName } } = useBrowseState(); + const domain = useDomain(); + + const { data, isPending, isError } = useQuery({ + queryKey: ['tree', repoName, revisionName], + queryFn: () => unwrapServiceError(getTree(repoName, revisionName, domain)), + }); + + if (isPending) { + return

Loading...

+ } + + if (isError) { + return

Error

+ } + + return ( + + ) +} + const buildCollapsableTree = (tree: RawFileTreeNode): FileTreeNode => { return { ...tree, @@ -41,8 +66,13 @@ const transformTree = ( } } -export const FileTreePanel = ({ tree: _tree, path, repoName, revisionName }: FileTreePanelProps) => { +interface PureFileTreePanelProps { + tree: RawFileTreeNode; + repoName: string; + revisionName: string; +} +const PureFileTreePanel = ({ tree: _tree, repoName, revisionName }: PureFileTreePanelProps) => { const [tree, setTree] = useState(buildCollapsableTree(_tree)); const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => { @@ -54,19 +84,10 @@ export const FileTreePanel = ({ tree: _tree, path, repoName, revisionName }: Fil })); }, [tree]); - useEffect(() => { - const parts = path.split('/'); - let currentPath = ''; - parts.forEach((part) => { - currentPath = currentPath ? `${currentPath}/${part}` : part; - console.log('setting collapse for', currentPath); - setIsCollapsed(currentPath, false); - }); - }, [path]); - const { navigateToPath } = useBrowseNavigation(); const renderTree = useCallback((nodes: FileTreeNode, depth = 0) => { + console.log('rendering tree'); return (
{nodes.children.map((node) => { @@ -106,13 +127,9 @@ export const FileTreePanel = ({ tree: _tree, path, repoName, revisionName }: Fil const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); return ( - - - {renderedTree} - - + + {renderedTree} + ) } From 66ce25b829171d5c6d68175ca2c9539a415bec31 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 4 Jun 2025 12:19:30 -0700 Subject: [PATCH 05/18] Collapsible state + more style improvements --- .../[domain]/browse/browseStateProvider.tsx | 2 + .../browse/components/bottomPanel.tsx | 8 +- .../web/src/app/[domain]/browse/layout.tsx | 28 +- packages/web/src/app/[domain]/search/page.tsx | 2 +- .../fileTree/components/fileTreePanel.tsx | 426 ++++++++++++------ .../fileTree/components/pureFileTreePanel.tsx | 143 ++++++ 6 files changed, 442 insertions(+), 167 deletions(-) create mode 100644 packages/web/src/features/fileTree/components/pureFileTreePanel.tsx diff --git a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx index 0b6e75f30..ec40aafdd 100644 --- a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx +++ b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx @@ -11,6 +11,7 @@ export interface BrowseState { language: string; } isBottomPanelCollapsed: boolean; + isFileTreePanelCollapsed: boolean; activeExploreMenuTab: "references" | "definitions"; bottomPanelSize: number; repoName: string; @@ -20,6 +21,7 @@ export interface BrowseState { const defaultState: BrowseState = { selectedSymbolInfo: undefined, isBottomPanelCollapsed: true, + isFileTreePanelCollapsed: false, activeExploreMenuTab: "references", bottomPanelSize: 35, repoName: '', diff --git a/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx index 90ec4b742..4a155207a 100644 --- a/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx +++ b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx @@ -20,7 +20,11 @@ export const BOTTOM_PANEL_MIN_SIZE = 35; export const BOTTOM_PANEL_MAX_SIZE = 65; const CODE_NAV_DOCS_URL = "https://docs.sourcebot.dev/docs/features/code-navigation"; -export const BottomPanel = () => { +interface BottomPanelProps { + order: number; +} + +export const BottomPanel = ({ order }: BottomPanelProps) => { const panelRef = useRef(null); const hasCodeNavEntitlement = useHasEntitlement("code-nav"); const domain = useDomain(); @@ -94,7 +98,7 @@ export const BottomPanel = () => { updateBrowseState({ bottomPanelSize: size }); } }} - order={2} + order={order} id={"bottom-panel"} > {!hasCodeNavEntitlement ? ( diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx index 82423b303..fe8b3b501 100644 --- a/packages/web/src/app/[domain]/browse/layout.tsx +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -51,35 +51,33 @@ export default function Layout({
+ + + + - - - - {codePreviewPanel} + + - -
diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index e307d74c6..b44342ac4 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -267,7 +267,7 @@ const PanelGroup = ({ > + + + + + Close file tree + + +

File Tree

+
+ + {isPending ? ( + + ) : + isError ? ( +
+

Error loading file tree

+
+ ) : ( + + )} + +
+ {isFileTreePanelCollapsed && ( +
+ + +
- {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} - - ); - })} - - ); - }, []); - - const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); - - return ( - - {renderedTree} - + + + + + + + Open file tree + + + + )} + ) } -const FileTreeItem = ({ - node, - onClick, -}: { - node: FileTreeNode, - onClick: () => void -}) => { - const iconName = useMemo(() => { - if (node.type === 'tree') { - const icon = getIconForFolder(node.name); - if (icon) { - const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; - return iconName; - } - } else if (node.type === 'blob') { - const icon = getIconForFile(node.name); - if (icon) { - const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; - return iconName; - } - } - - return "vscode-icons:file-type-unknown"; - }, [node.type]); +const FileTreePanelSkeleton = () => { return ( -
-
- {node.type === 'tree' && ( - node.isCollapsed ? ( - - ) : ( - - ) - )} -
- - {node.name} +
+ {/* Root level items */} +
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
) -} +} \ No newline at end of file diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx new file mode 100644 index 000000000..f59dd3c8d --- /dev/null +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { FileTreeNode as RawFileTreeNode } from "../actions"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { useCallback, useMemo, useState } from "react"; +import { getIconForFile, getIconForFolder } from "vscode-icons-js"; +import { Icon } from '@iconify/react'; +import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; +import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; + +export type FileTreeNode = Omit & { + isCollapsed: boolean; + children: FileTreeNode[]; +} + +const buildCollapsableTree = (tree: RawFileTreeNode): FileTreeNode => { + return { + ...tree, + isCollapsed: true, + children: tree.children.map(buildCollapsableTree), + } +} + +const transformTree = ( + tree: FileTreeNode, + transform: (node: FileTreeNode) => FileTreeNode +): FileTreeNode => { + const newNode = transform(tree); + const newChildren = tree.children.map(child => transformTree(child, transform)); + return { + ...newNode, + children: newChildren, + } +} + +interface PureFileTreePanelProps { + tree: RawFileTreeNode; + repoName: string; + revisionName: string; +} + +export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName }: PureFileTreePanelProps) => { + const [tree, setTree] = useState(buildCollapsableTree(_tree)); + + const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => { + setTree(transformTree(tree, (currentNode) => { + if (currentNode.path === path) { + currentNode.isCollapsed = isCollapsed; + } + return currentNode; + })); + }, [tree]); + + const { navigateToPath } = useBrowseNavigation(); + + const renderTree = useCallback((nodes: FileTreeNode, depth = 0) => { + return ( +
+ {nodes.children.map((node) => { + return ( + <> +
{ + if (node.type === 'tree') { + setIsCollapsed(node.path, !node.isCollapsed); + } + else if (node.type === 'blob') { + navigateToPath({ + repoName: repoName, + revisionName: revisionName, + path: node.path, + pathType: 'blob', + }); + } + }} + > + +
+ {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} + + ); + })} +
+ ); + }, []); + + const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); + + return ( + + {renderedTree} + + + ) +} + +const FileTreeItem = ({ + node, +}: { + node: FileTreeNode, +}) => { + const iconName = useMemo(() => { + if (node.type === 'tree') { + const icon = getIconForFolder(node.name); + if (icon) { + const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; + return iconName; + } + } else if (node.type === 'blob') { + const icon = getIconForFile(node.name); + if (icon) { + const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; + return iconName; + } + } + + return "vscode-icons:file-type-unknown"; + }, [node.type]); + + return ( +
+
+ {node.type === 'tree' && ( + node.isCollapsed ? ( + + ) : ( + + ) + )} +
+ + {node.name} +
+ ) +} From 4250553d00bb5146f71ac633c88b020e04b059f6 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 4 Jun 2025 12:30:35 -0700 Subject: [PATCH 06/18] make file tree keyboard nav work --- .../fileTree/components/pureFileTreePanel.tsx | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx index f59dd3c8d..e9a7a08d8 100644 --- a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -53,6 +53,20 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName }: PureF const { navigateToPath } = useBrowseNavigation(); + const onNodeClicked = useCallback((node: FileTreeNode) => { + if (node.type === 'tree') { + setIsCollapsed(node.path, !node.isCollapsed); + } + else if (node.type === 'blob') { + navigateToPath({ + repoName: repoName, + revisionName: revisionName, + path: node.path, + pathType: 'blob', + }); + } + }, [setIsCollapsed, navigateToPath, repoName, revisionName]); + const renderTree = useCallback((nodes: FileTreeNode, depth = 0) => { return (
@@ -62,19 +76,14 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName }: PureF
{ - if (node.type === 'tree') { - setIsCollapsed(node.path, !node.isCollapsed); - } - else if (node.type === 'blob') { - navigateToPath({ - repoName: repoName, - revisionName: revisionName, - path: node.path, - pathType: 'blob', - }); + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onNodeClicked(node); } }} + onClick={() => onNodeClicked(node)} > {renderedTree} @@ -126,7 +134,9 @@ const FileTreeItem = ({ }, [node.type]); return ( -
+
{node.type === 'tree' && ( node.isCollapsed ? ( From df9243bc0e43324c153e032274e9fa39acdeaec7 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 4 Jun 2025 16:36:10 -0700 Subject: [PATCH 07/18] further wip: added useBrowseParams improved UX, etc --- .../app/[domain]/browse/[...path]/page.tsx | 175 ++++-------------- .../[domain]/browse/browseStateProvider.tsx | 14 +- .../[domain]/browse/hooks/useBrowseParams.ts | 46 +++++ .../web/src/app/[domain]/browse/layout.tsx | 24 +-- .../fileTree/components/fileTreePanel.tsx | 16 +- .../fileTree/components/pureFileTreePanel.tsx | 78 +++++++- 6 files changed, 165 insertions(+), 188 deletions(-) create mode 100644 packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index ca0950b82..ca825b5d0 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -1,153 +1,46 @@ -import { FileHeader } from "@/app/[domain]/components/fileHeader"; -import { Separator } from '@/components/ui/separator'; -import { getFileSource } from '@/features/search/fileSourceApi'; -import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"; -import { base64Decode } from "@/lib/utils"; -import { ErrorCode } from "@/lib/errorCodes"; -import { LuFileX2, LuBookX } from "react-icons/lu"; -import { notFound } from "next/navigation"; -import { ServiceErrorException } from "@/lib/serviceError"; -import { getRepoInfoByName } from "@/actions"; -import { CodePreviewPanel } from "./components/codePreviewPanel"; -import Image from "next/image"; - -interface BrowsePageProps { - params: { - path: string[]; - domain: string; - }; -} - -export default async function BrowsePage({ - params, -}: BrowsePageProps) { - const rawPath = decodeURIComponent(params.path.join('/')); - const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//); - if (sentinalIndex === -1) { - notFound(); - } - - const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@'); - const repoName = repoAndRevisionName[0]; - const revisionName = repoAndRevisionName.length > 1 ? repoAndRevisionName[1] : undefined; - - const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => { - const path = rawPath.substring(sentinalIndex + '/-/'.length); - const pathType = path.startsWith('tree/') ? 'tree' : 'blob'; - switch (pathType) { - case 'tree': - return { - path: path.substring('tree/'.length), - pathType, - }; - case 'blob': - return { - path: path.substring('blob/'.length), - pathType, - }; - } - })(); - - const repoInfo = await getRepoInfoByName(repoName, params.domain); - if (isServiceError(repoInfo)) { - if (repoInfo.errorCode === ErrorCode.NOT_FOUND) { - return ( -
-
- - Repository not found -
-
- ); - } +'use client'; - throw new ServiceErrorException(repoInfo); - } +import { base64Decode, unwrapServiceError } from "@/lib/utils"; +import { CodePreviewPanel } from "./components/codePreviewPanel"; +import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; +import { useQuery } from "@tanstack/react-query"; +import { getFileSource } from "@/features/search/fileSourceApi"; +import { useDomain } from "@/hooks/useDomain"; +import { Loader2 } from "lucide-react"; + +export default function BrowsePage() { + const { path, repoName, revisionName } = useBrowseParams(); + const domain = useDomain(); + + const { data: fileSourceResponse, isPending, isError } = useQuery({ + queryKey: ['fileSource', repoName, revisionName, path, domain], + queryFn: () => unwrapServiceError(getFileSource({ + fileName: path, + repository: repoName, + branch: revisionName + }, domain)), + }); - if (pathType === 'tree') { - // @todo : proper tree handling + if (isPending) { return ( - <> - Tree view not supported - +
+ + Loading... +
) } - const fileSourceResponse = await getFileSource({ - fileName: path, - repository: repoName, - branch: revisionName ?? 'HEAD', - }, params.domain); - - if (isServiceError(fileSourceResponse)) { - if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) { - return ( -
-
- - File not found -
-
- ) - } - - throw new ServiceErrorException(fileSourceResponse); + if (isError) { + return
error
} - const codeHostInfo = getCodeHostInfoForRepo({ - codeHostType: repoInfo.codeHostType, - name: repoInfo.name, - displayName: repoInfo.displayName, - webUrl: repoInfo.webUrl, - }); - return ( + source={base64Decode(fileSourceResponse.source)} + language={fileSourceResponse.language} + repoName={repoName} + path={path} + revisionName={revisionName ?? 'HEAD'} + /> ) - - // return ( - //
- //
- // - // {(fileSourceResponse.webUrl && codeHostInfo) && ( - // - // {codeHostInfo.codeHostName} - // Open in {codeHostInfo.codeHostName} - // - // )} - //
- // - // - //
- // ) } diff --git a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx index ec40aafdd..78d8d5d26 100644 --- a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx +++ b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx @@ -14,8 +14,6 @@ export interface BrowseState { isFileTreePanelCollapsed: boolean; activeExploreMenuTab: "references" | "definitions"; bottomPanelSize: number; - repoName: string; - revisionName: string; } const defaultState: BrowseState = { @@ -24,8 +22,6 @@ const defaultState: BrowseState = { isFileTreePanelCollapsed: false, activeExploreMenuTab: "references", bottomPanelSize: 35, - repoName: '', - revisionName: '', }; export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState"; @@ -40,16 +36,10 @@ export const BrowseStateContext = createContext<{ interface BrowseStateProviderProps { children: React.ReactNode; - repoName: string; - revisionName: string; } -export const BrowseStateProvider = ({ children, repoName, revisionName }: BrowseStateProviderProps) => { - const [state, setState] = useState({ - ...defaultState, - repoName, - revisionName, - }); +export const BrowseStateProvider = ({ children }: BrowseStateProviderProps) => { + const [state, setState] = useState(defaultState); const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM); diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts new file mode 100644 index 000000000..3ca3392b7 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts @@ -0,0 +1,46 @@ +'use client'; + +import { usePathname } from "next/navigation"; + +export const useBrowseParams = () => { + const pathname = usePathname(); + + const startIndex = pathname.indexOf('/browse/'); + if (startIndex === -1) { + throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/browse/"`); + } + + const rawPath = pathname.substring(startIndex + '/browse/'.length); + const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//); + if (sentinalIndex === -1) { + throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/-/(tree|blob)/" pattern`); + } + + const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@'); + const repoName = repoAndRevisionName[0]; + const revisionName = repoAndRevisionName.length > 1 ? repoAndRevisionName[1] : undefined; + + const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => { + const path = rawPath.substring(sentinalIndex + '/-/'.length); + const pathType = path.startsWith('tree/') ? 'tree' : 'blob'; + switch (pathType) { + case 'tree': + return { + path: path.substring('tree/'.length), + pathType, + }; + case 'blob': + return { + path: path.substring('blob/'.length), + pathType, + }; + } + })(); + + return { + repoName, + revisionName, + path, + pathType, + } +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx index fe8b3b501..c7502fa9f 100644 --- a/packages/web/src/app/[domain]/browse/layout.tsx +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -7,7 +7,7 @@ import { BrowseStateProvider } from "./browseStateProvider"; import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel"; import { TopBar } from "@/app/[domain]/components/topBar"; import { Separator } from '@/components/ui/separator'; -import { notFound, usePathname } from "next/navigation"; +import { useBrowseParams } from "./hooks/useBrowseParams"; interface LayoutProps { children: React.ReactNode; @@ -20,28 +20,10 @@ export default function Layout({ children: codePreviewPanel, params, }: LayoutProps) { - const pathname = usePathname(); - - const startIndex = pathname.indexOf('/browse/'); - if (startIndex === -1) { - return notFound(); - } - - const rawPath = pathname.substring(startIndex + '/browse/'.length); - const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//); - if (sentinalIndex === -1) { - return notFound(); - } - - const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@'); - const repoName = repoAndRevisionName[0]; - const revisionName = repoAndRevisionName.length > 1 ? repoAndRevisionName[1] : undefined; + const { repoName, revisionName } = useBrowseParams(); return ( - +
{ const { state: { - repoName, - revisionName, isFileTreePanelCollapsed, }, updateBrowseState, } = useBrowseState(); + + const { repoName, revisionName, path } = useBrowseParams(); const domain = useDomain(); const fileTreePanelRef = useRef(null); - const { data, isPending, isError } = useQuery({ - queryKey: ['tree', repoName, revisionName], - queryFn: () => unwrapServiceError(getTree(repoName, revisionName, domain)), + const { data, isPending, isLoading, isError } = useQuery({ + queryKey: ['tree', repoName, revisionName, domain], + queryFn: () => unwrapServiceError(getTree(repoName, revisionName ?? 'HEAD', domain)), }); useHotkeys("mod+b", () => { @@ -99,7 +100,7 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {

File Tree

- {isPending ? ( + {(isPending || isLoading) ? ( ) : isError ? ( @@ -110,7 +111,8 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { )}
diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx index e9a7a08d8..62f1a0ca7 100644 --- a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -2,11 +2,16 @@ import { FileTreeNode as RawFileTreeNode } from "../actions"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useState, useEffect, useRef } from "react"; import { getIconForFile, getIconForFolder } from "vscode-icons-js"; import { Icon } from '@iconify/react'; import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import clsx from "clsx"; +import { useQueryClient } from "@tanstack/react-query"; +import { useDomain } from "@/hooks/useDomain"; +import { unwrapServiceError } from "@/lib/utils"; +import { getFileSource } from "@/features/search/fileSourceApi"; export type FileTreeNode = Omit & { isCollapsed: boolean; @@ -37,22 +42,56 @@ interface PureFileTreePanelProps { tree: RawFileTreeNode; repoName: string; revisionName: string; + path: string; } -export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName }: PureFileTreePanelProps) => { +export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: PureFileTreePanelProps) => { const [tree, setTree] = useState(buildCollapsableTree(_tree)); + const scrollAreaRef = useRef(null); + const queryClient = useQueryClient(); + const domain = useDomain(); + + useEffect(() => { + setTree(buildCollapsableTree(_tree)); + }, [_tree]); const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => { - setTree(transformTree(tree, (currentNode) => { + setTree(currentTree => transformTree(currentTree, (currentNode) => { if (currentNode.path === path) { currentNode.isCollapsed = isCollapsed; } return currentNode; })); - }, [tree]); + }, []); const { navigateToPath } = useBrowseNavigation(); + // When the path changes, expand all the folders up to the path + useEffect(() => { + const pathParts = path.split('/'); + let currentPath = ''; + for (let i = 0; i < pathParts.length; i++) { + currentPath += pathParts[i]; + setIsCollapsed(currentPath, false); + if (i < pathParts.length - 1) { + currentPath += '/'; + } + } + }, [path, setIsCollapsed]); + + // When the path changes, scroll to the file in the tree + useEffect(() => { + const activeElement = document.querySelector(`[data-path="${path}"]`); + if (!activeElement) { + return; + } + + activeElement.scrollIntoView({ + behavior: 'instant', + block: 'nearest', + }); + }, [path]); + const onNodeClicked = useCallback((node: FileTreeNode) => { if (node.type === 'tree') { setIsCollapsed(node.path, !node.isCollapsed); @@ -67,6 +106,26 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName }: PureF } }, [setIsCollapsed, navigateToPath, repoName, revisionName]); + // Prefetch the file source when the user hovers over a file. + // This is to try and mitigate having a loading spinner appear when the user clicks on a file + // to open it. + const onNodeMouseEnter = useCallback((node: FileTreeNode) => { + if (node.type !== 'blob') { + return; + } + + queryClient.prefetchQuery({ + queryKey: ['fileSource', repoName, revisionName, node.path, domain], + queryFn: () => unwrapServiceError(getFileSource({ + fileName: node.path, + repository: repoName, + branch: revisionName + }, domain)), + staleTime: Infinity, + }) + + }, [queryClient, repoName, revisionName, domain]); + const renderTree = useCallback((nodes: FileTreeNode, depth = 0) => { return (
@@ -74,7 +133,10 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName }: PureF return ( <>
{ @@ -84,6 +146,7 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName }: PureF } }} onClick={() => onNodeClicked(node)} + onMouseEnter={() => onNodeMouseEnter(node)} > ); - }, []); + }, [onNodeClicked, onNodeMouseEnter, path]); const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); return ( {renderedTree} @@ -131,7 +195,7 @@ const FileTreeItem = ({ } return "vscode-icons:file-type-unknown"; - }, [node.type]); + }, [node.name, node.type]); return (
Date: Thu, 5 Jun 2025 14:50:43 -0700 Subject: [PATCH 08/18] fix scrolling behaviour --- packages/web/package.json | 1 + .../web/src/app/[domain]/browse/layout.tsx | 2 +- .../fileTree/components/fileTreePanel.tsx | 8 +- .../fileTree/components/pureFileTreePanel.tsx | 100 ++++++++++-------- yarn.lock | 17 +++ 5 files changed, 80 insertions(+), 48 deletions(-) diff --git a/packages/web/package.json b/packages/web/package.json index 1189ae3ba..67db67506 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -137,6 +137,7 @@ "react-hotkeys-hook": "^4.5.1", "react-icons": "^5.3.0", "react-resizable-panels": "^2.1.1", + "scroll-into-view-if-needed": "^3.1.0", "server-only": "^0.0.1", "sharp": "^0.33.5", "simple-git": "^3.27.0", diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx index c7502fa9f..83d92c53b 100644 --- a/packages/web/src/app/[domain]/browse/layout.tsx +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -42,7 +42,7 @@ export default function Layout({ { updateBrowseState, } = useBrowseState(); + const domain = useDomain(); const { repoName, revisionName, path } = useBrowseParams(); - const domain = useDomain(); const fileTreePanelRef = useRef(null); - const { data, isPending, isLoading, isError } = useQuery({ + const { data, isPending, isError } = useQuery({ queryKey: ['tree', repoName, revisionName, domain], queryFn: () => unwrapServiceError(getTree(repoName, revisionName ?? 'HEAD', domain)), }); @@ -100,7 +100,7 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {

File Tree

- {(isPending || isLoading) ? ( + {isPending ? ( ) : isError ? ( diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx index 62f1a0ca7..56ef53608 100644 --- a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -12,6 +12,8 @@ import { useQueryClient } from "@tanstack/react-query"; import { useDomain } from "@/hooks/useDomain"; import { unwrapServiceError } from "@/lib/utils"; import { getFileSource } from "@/features/search/fileSourceApi"; +import scrollIntoView from 'scroll-into-view-if-needed' + export type FileTreeNode = Omit & { isCollapsed: boolean; @@ -50,11 +52,18 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: const scrollAreaRef = useRef(null); const queryClient = useQueryClient(); const domain = useDomain(); + const { navigateToPath } = useBrowseNavigation(); + // @note: When `_tree` changes, it indicates that a new tree has been loaded. + // In that case, we need to rebuild the collapsable tree. useEffect(() => { setTree(buildCollapsableTree(_tree)); }, [_tree]); + useEffect(() => { + console.log(repoName, revisionName, path, tree.children[0].path); + }, [tree, repoName, revisionName, path]); + const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => { setTree(currentTree => transformTree(currentTree, (currentNode) => { if (currentNode.path === path) { @@ -64,8 +73,6 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: })); }, []); - const { navigateToPath } = useBrowseNavigation(); - // When the path changes, expand all the folders up to the path useEffect(() => { const pathParts = path.split('/'); @@ -79,19 +86,6 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: } }, [path, setIsCollapsed]); - // When the path changes, scroll to the file in the tree - useEffect(() => { - const activeElement = document.querySelector(`[data-path="${path}"]`); - if (!activeElement) { - return; - } - - activeElement.scrollIntoView({ - behavior: 'instant', - block: 'nearest', - }); - }, [path]); - const onNodeClicked = useCallback((node: FileTreeNode) => { if (node.type === 'tree') { setIsCollapsed(node.path, !node.isCollapsed); @@ -103,12 +97,14 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: path: node.path, pathType: 'blob', }); + } }, [setIsCollapsed, navigateToPath, repoName, revisionName]); - // Prefetch the file source when the user hovers over a file. - // This is to try and mitigate having a loading spinner appear when the user clicks on a file - // to open it. + // @note: We prefetch the file source when the user hovers over a file. + // This is to try and mitigate having a loading spinner appear when + // the user clicks on a file to open it. + // @see: /browse/[...path]/page.tsx const onNodeMouseEnter = useCallback((node: FileTreeNode) => { if (node.type !== 'blob') { return; @@ -119,10 +115,9 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: queryFn: () => unwrapServiceError(getFileSource({ fileName: node.path, repository: repoName, - branch: revisionName + branch: revisionName, }, domain)), - staleTime: Infinity, - }) + }); }, [queryClient, repoName, revisionName, domain]); @@ -132,27 +127,13 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: {nodes.children.map((node) => { return ( <> -
{ - if (e.key === 'Enter') { - e.preventDefault(); - onNodeClicked(node); - } - }} - onClick={() => onNodeClicked(node)} - onMouseEnter={() => onNodeMouseEnter(node)} - > - -
+ {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} ); @@ -176,9 +157,29 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: const FileTreeItem = ({ node, + isActive, + depth, + onNodeClicked, + onNodeMouseEnter, }: { node: FileTreeNode, + isActive: boolean, + depth: number, + onNodeClicked: (node: FileTreeNode) => void, + onNodeMouseEnter: (node: FileTreeNode) => void, }) => { + const ref = useRef(null); + + useEffect(() => { + if (isActive && ref.current) { + scrollIntoView(ref.current, { + scrollMode: 'if-needed', + block: 'center', + behavior: 'instant', + }); + } + }, [isActive]); + const iconName = useMemo(() => { if (node.type === 'tree') { const icon = getIconForFolder(node.name); @@ -199,7 +200,20 @@ const FileTreeItem = ({ return (
{ + if (e.key === 'Enter') { + e.preventDefault(); + onNodeClicked(node); + } + }} + onClick={() => onNodeClicked(node)} + onMouseEnter={() => onNodeMouseEnter(node)} >
{node.type === 'tree' && ( diff --git a/yarn.lock b/yarn.lock index 2f7eb2054..7205c98fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6031,6 +6031,7 @@ __metadata: react-hotkeys-hook: "npm:^4.5.1" react-icons: "npm:^5.3.0" react-resizable-panels: "npm:^2.1.1" + scroll-into-view-if-needed: "npm:^3.1.0" server-only: "npm:^0.0.1" sharp: "npm:^0.33.5" simple-git: "npm:^3.27.0" @@ -8305,6 +8306,13 @@ __metadata: languageName: node linkType: hard +"compute-scroll-into-view@npm:^3.0.2": + version: 3.1.1 + resolution: "compute-scroll-into-view@npm:3.1.1" + checksum: 10c0/59761ed62304a9599b52ad75d0d6fbf0669ee2ab7dd472fdb0ad9da36628414c014dea7b5810046560180ad30ffec52a953d19297f66a1d4f3aa0999b9d2521d + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -14546,6 +14554,15 @@ __metadata: languageName: node linkType: hard +"scroll-into-view-if-needed@npm:^3.1.0": + version: 3.1.0 + resolution: "scroll-into-view-if-needed@npm:3.1.0" + dependencies: + compute-scroll-into-view: "npm:^3.0.2" + checksum: 10c0/1f46b090e1e04fcfdef1e384f6d7e615f9f84d4176faf4dbba7347cc0a6e491e5d578eaf4dbe9618dd3d8d38efafde58535b3e00f2a21ce4178c14be364850ff + languageName: node + linkType: hard + "selderee@npm:^0.11.0": version: 0.11.0 resolution: "selderee@npm:0.11.0" From 8db87efcfe1ad9d464949c31532077b3e37ac7b9 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 5 Jun 2025 15:41:05 -0700 Subject: [PATCH 09/18] prefetch source files on hover over reference --- .../components/exploreMenu/referenceList.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx index c82782dd0..fc5de453b 100644 --- a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx +++ b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx @@ -5,11 +5,13 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; import { FindRelatedSymbolsResponse } from "@/features/codeNav/types"; import { RepositoryInfo, SourceRange } from "@/features/search/types"; -import { base64Decode } from "@/lib/utils"; +import { base64Decode, unwrapServiceError } from "@/lib/utils"; import { useMemo, useRef } from "react"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useVirtualizer } from "@tanstack/react-virtual"; - +import { useQueryClient } from "@tanstack/react-query"; +import { getFileSource } from "@/features/search/fileSourceApi"; +import { useDomain } from "@/hooks/useDomain"; interface ReferenceListProps { data: FindRelatedSymbolsResponse; revisionName: string; @@ -31,6 +33,8 @@ export const ReferenceList = ({ const { navigateToPath } = useBrowseNavigation(); const captureEvent = useCaptureEvent(); + const queryClient = useQueryClient(); + const domain = useDomain(); // Virtualization setup const parentRef = useRef(null); @@ -119,6 +123,20 @@ export const ReferenceList = ({ highlightRange: match.range, }) }} + // @note: We prefetch the file source when the user hovers over a file. + // This is to try and mitigate having a loading spinner appear when + // the user clicks on a file to open it. + // @see: /browse/[...path]/page.tsx + onMouseEnter={() => { + queryClient.prefetchQuery({ + queryKey: ['fileSource', file.repository, revisionName, file.fileName, domain], + queryFn: () => unwrapServiceError(getFileSource({ + fileName: file.fileName, + repository: file.repository, + branch: revisionName, + }, domain)), + }); + }} /> ))}
@@ -136,6 +154,7 @@ interface ReferenceListItemProps { range: SourceRange; language: string; onClick: () => void; + onMouseEnter: () => void; } const ReferenceListItem = ({ @@ -143,6 +162,7 @@ const ReferenceListItem = ({ range, language, onClick, + onMouseEnter, }: ReferenceListItemProps) => { const decodedLineContent = useMemo(() => { return base64Decode(lineContent); @@ -154,6 +174,7 @@ const ReferenceListItem = ({
Date: Thu, 5 Jun 2025 15:42:00 -0700 Subject: [PATCH 10/18] Add file header to source view --- .../app/[domain]/browse/[...path]/page.tsx | 75 ++++++++++++++++--- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index ca825b5d0..a61071e1e 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -1,18 +1,24 @@ 'use client'; -import { base64Decode, unwrapServiceError } from "@/lib/utils"; +import { base64Decode, getCodeHostInfoForRepo, unwrapServiceError } from "@/lib/utils"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; import { useQuery } from "@tanstack/react-query"; import { getFileSource } from "@/features/search/fileSourceApi"; import { useDomain } from "@/hooks/useDomain"; import { Loader2 } from "lucide-react"; +import { Separator } from "@/components/ui/separator"; +import { getRepoInfoByName } from "@/actions"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { FileHeader } from "../../components/fileHeader"; +import { useMemo } from "react"; export default function BrowsePage() { const { path, repoName, revisionName } = useBrowseParams(); const domain = useDomain(); - const { data: fileSourceResponse, isPending, isError } = useQuery({ + const { data: fileSourceResponse, isPending: isFileSourcePending, isError: isFileSourceError } = useQuery({ queryKey: ['fileSource', repoName, revisionName, path, domain], queryFn: () => unwrapServiceError(getFileSource({ fileName: path, @@ -21,7 +27,25 @@ export default function BrowsePage() { }, domain)), }); - if (isPending) { + const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({ + queryKey: ['repoInfo', repoName, domain], + queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)), + }); + + const codeHostInfo = useMemo(() => { + if (!repoInfoResponse) { + return undefined; + } + + return getCodeHostInfoForRepo({ + codeHostType: repoInfoResponse.codeHostType, + name: repoInfoResponse.name, + displayName: repoInfoResponse.displayName, + webUrl: repoInfoResponse.webUrl, + }); + }, [repoInfoResponse]); + + if (isFileSourcePending || isRepoInfoPending) { return (
@@ -30,17 +54,46 @@ export default function BrowsePage() { ) } - if (isError) { + if (isFileSourceError || isRepoInfoError) { return
error
} return ( - +
+
+ + {(fileSourceResponse.webUrl && codeHostInfo) && ( + + {codeHostInfo.codeHostName} + Open in {codeHostInfo.codeHostName} + + )} +
+ + +
) } From a46c0850f9525e6af23f53f0ea6083cba1d840a7 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 5 Jun 2025 17:40:03 -0700 Subject: [PATCH 11/18] Add file tree preview --- packages/backend/src/utils.ts | 2 + .../[...path]/components/codePreviewPanel.tsx | 297 ++++++------------ .../components/pureCodePreviewPanel.tsx | 220 +++++++++++++ .../[...path]/components/treePreviewPanel.tsx | 117 +++++++ .../app/[domain]/browse/[...path]/page.tsx | 99 +----- packages/web/src/features/fileTree/actions.ts | 117 +++++-- .../components/fileTreeItemComponent.tsx | 90 ++++++ .../fileTree/components/fileTreePanel.tsx | 11 +- .../fileTree/components/pureFileTreePanel.tsx | 106 +------ 9 files changed, 640 insertions(+), 419 deletions(-) create mode 100644 packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx create mode 100644 packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx create mode 100644 packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 5157c022e..3afcfe09e 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -70,6 +70,8 @@ export const arraysEqualShallow = (a?: readonly T[], b?: readonly T[]) => { return true; } +// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`. +// @todo: we should move this to a shared package. export const getRepoPath = (repo: Repo, ctx: AppContext): { path: string, isReadOnly: boolean } => { // If we are dealing with a local repository, then use that as the path. // Mark as read-only since we aren't guaranteed to have write access to the local filesystem. diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx index 25489588c..7608539c9 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx @@ -1,220 +1,99 @@ 'use client'; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup"; -import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension"; -import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo"; -import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; -import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension"; -import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; -import { useKeymapExtension } from "@/hooks/useKeymapExtension"; -import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; -import { search } from "@codemirror/search"; -import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { EditorContextMenu } from "../../../components/editorContextMenu"; -import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation"; -import { useBrowseState } from "../../hooks/useBrowseState"; -import { rangeHighlightingExtension } from "./rangeHighlightingExtension"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; - -interface CodePreviewPanelProps { - path: string; - repoName: string; - revisionName: string; - source: string; - language: string; -} - -export const CodePreviewPanel = ({ - source, - language, - path, - repoName, - revisionName, -}: CodePreviewPanelProps) => { - const [editorRef, setEditorRef] = useState(null); - const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view); - const [currentSelection, setCurrentSelection] = useState(); - const keymapExtension = useKeymapExtension(editorRef?.view); - const hasCodeNavEntitlement = useHasEntitlement("code-nav"); - const { updateBrowseState } = useBrowseState(); - const { navigateToPath } = useBrowseNavigation(); - const captureEvent = useCaptureEvent(); - - const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM); - const highlightRange = useMemo((): BrowseHighlightRange | undefined => { - if (!highlightRangeQuery) { - return; +import { base64Decode, getCodeHostInfoForRepo, unwrapServiceError } from "@/lib/utils"; +import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; +import { useQuery } from "@tanstack/react-query"; +import { getFileSource } from "@/features/search/fileSourceApi"; +import { useDomain } from "@/hooks/useDomain"; +import { Loader2 } from "lucide-react"; +import { Separator } from "@/components/ui/separator"; +import { getRepoInfoByName } from "@/actions"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { useMemo } from "react"; +import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; +import { FileHeader } from "@/app/[domain]/components/fileHeader"; + +export const CodePreviewPanel = () => { + const { path, repoName, revisionName } = useBrowseParams(); + const domain = useDomain(); + + const { data: fileSourceResponse, isPending: isFileSourcePending, isError: isFileSourceError } = useQuery({ + queryKey: ['fileSource', repoName, revisionName, path, domain], + queryFn: () => unwrapServiceError(getFileSource({ + fileName: path, + repository: repoName, + branch: revisionName + }, domain)), + }); + + const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({ + queryKey: ['repoInfo', repoName, domain], + queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)), + }); + + const codeHostInfo = useMemo(() => { + if (!repoInfoResponse) { + return undefined; } - // Highlight ranges can be formatted in two ways: - // 1. start_line,end_line (no column specified) - // 2. start_line:start_column,end_line:end_column (column specified) - const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/; - if (!rangeRegex.test(highlightRangeQuery)) { - return; - } - - const [start, end] = highlightRangeQuery.split(',').map((range) => { - if (range.includes(':')) { - return range.split(':').map((val) => parseInt(val, 10)); - } - // For line-only format, use column 1 for start and last column for end - const line = parseInt(range, 10); - return [line]; + return getCodeHostInfoForRepo({ + codeHostType: repoInfoResponse.codeHostType, + name: repoInfoResponse.name, + displayName: repoInfoResponse.displayName, + webUrl: repoInfoResponse.webUrl, }); + }, [repoInfoResponse]); - if (start.length === 1 || end.length === 1) { - return { - start: { - lineNumber: start[0], - }, - end: { - lineNumber: end[0], - } - } - } else { - return { - start: { - lineNumber: start[0], - column: start[1], - }, - end: { - lineNumber: end[0], - column: end[1], - } - } - } - - }, [highlightRangeQuery]); - - const extensions = useMemo(() => { - return [ - languageExtension, - EditorView.lineWrapping, - keymapExtension, - search({ - top: true, - }), - EditorView.updateListener.of((update: ViewUpdate) => { - if (update.selectionSet) { - setCurrentSelection(update.state.selection.main); - } - }), - highlightRange ? rangeHighlightingExtension(highlightRange) : [], - hasCodeNavEntitlement ? symbolHoverTargetsExtension : [], - ]; - }, [ - keymapExtension, - languageExtension, - highlightRange, - hasCodeNavEntitlement, - ]); - - // Scroll the highlighted range into view. - useEffect(() => { - if (!highlightRange || !editorRef || !editorRef.state) { - return; - } - - const doc = editorRef.state.doc; - const { start, end } = highlightRange; - const selection = EditorSelection.range( - doc.line(start.lineNumber).from, - doc.line(end.lineNumber).from, - ); - - editorRef.view?.dispatch({ - effects: [ - EditorView.scrollIntoView(selection, { y: "center" }), - ] - }); - }, [editorRef, highlightRange]); + if (isFileSourcePending || isRepoInfoPending) { + return ( +
+ + Loading... +
+ ) + } - const onFindReferences = useCallback((symbolName: string) => { - captureEvent('wa_browse_find_references_pressed', {}); - - updateBrowseState({ - selectedSymbolInfo: { - repoName, - symbolName, - revisionName, - language, - }, - isBottomPanelCollapsed: false, - activeExploreMenuTab: "references", - }) - }, [captureEvent, updateBrowseState, repoName, revisionName, language]); - - - // If we resolve multiple matches, instead of navigating to the first match, we should - // instead popup the bottom sheet with the list of matches. - const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { - captureEvent('wa_browse_goto_definition_pressed', {}); - - if (symbolDefinitions.length === 0) { - return; - } - - if (symbolDefinitions.length === 1) { - const symbolDefinition = symbolDefinitions[0]; - const { fileName, repoName } = symbolDefinition; - - navigateToPath({ - repoName, - revisionName, - path: fileName, - pathType: 'blob', - highlightRange: symbolDefinition.range, - }) - } else { - updateBrowseState({ - selectedSymbolInfo: { - symbolName, - repoName, - revisionName, - language, - }, - activeExploreMenuTab: "definitions", - isBottomPanelCollapsed: false, - }) - } - }, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]); - - const theme = useCodeMirrorTheme(); + if (isFileSourceError || isRepoInfoError) { + return
Error loading file source
+ } return ( - - - {editorRef && editorRef.view && currentSelection && ( - + <> +
+ + {(fileSourceResponse.webUrl && codeHostInfo) && ( + + {codeHostInfo.codeHostName} + Open in {codeHostInfo.codeHostName} + )} - {editorRef && hasCodeNavEntitlement && ( - - )} - - - +
+ + + ) -} - +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx new file mode 100644 index 000000000..c8f8384f4 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup"; +import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension"; +import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; +import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension"; +import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; +import { useKeymapExtension } from "@/hooks/useKeymapExtension"; +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { search } from "@codemirror/search"; +import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { EditorContextMenu } from "../../../components/editorContextMenu"; +import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation"; +import { useBrowseState } from "../../hooks/useBrowseState"; +import { rangeHighlightingExtension } from "./rangeHighlightingExtension"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +interface PureCodePreviewPanelProps { + path: string; + repoName: string; + revisionName: string; + source: string; + language: string; +} + +export const PureCodePreviewPanel = ({ + source, + language, + path, + repoName, + revisionName, +}: PureCodePreviewPanelProps) => { + const [editorRef, setEditorRef] = useState(null); + const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view); + const [currentSelection, setCurrentSelection] = useState(); + const keymapExtension = useKeymapExtension(editorRef?.view); + const hasCodeNavEntitlement = useHasEntitlement("code-nav"); + const { updateBrowseState } = useBrowseState(); + const { navigateToPath } = useBrowseNavigation(); + const captureEvent = useCaptureEvent(); + + const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM); + const highlightRange = useMemo((): BrowseHighlightRange | undefined => { + if (!highlightRangeQuery) { + return; + } + + // Highlight ranges can be formatted in two ways: + // 1. start_line,end_line (no column specified) + // 2. start_line:start_column,end_line:end_column (column specified) + const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/; + if (!rangeRegex.test(highlightRangeQuery)) { + return; + } + + const [start, end] = highlightRangeQuery.split(',').map((range) => { + if (range.includes(':')) { + return range.split(':').map((val) => parseInt(val, 10)); + } + // For line-only format, use column 1 for start and last column for end + const line = parseInt(range, 10); + return [line]; + }); + + if (start.length === 1 || end.length === 1) { + return { + start: { + lineNumber: start[0], + }, + end: { + lineNumber: end[0], + } + } + } else { + return { + start: { + lineNumber: start[0], + column: start[1], + }, + end: { + lineNumber: end[0], + column: end[1], + } + } + } + + }, [highlightRangeQuery]); + + const extensions = useMemo(() => { + return [ + languageExtension, + EditorView.lineWrapping, + keymapExtension, + search({ + top: true, + }), + EditorView.updateListener.of((update: ViewUpdate) => { + if (update.selectionSet) { + setCurrentSelection(update.state.selection.main); + } + }), + highlightRange ? rangeHighlightingExtension(highlightRange) : [], + hasCodeNavEntitlement ? symbolHoverTargetsExtension : [], + ]; + }, [ + keymapExtension, + languageExtension, + highlightRange, + hasCodeNavEntitlement, + ]); + + // Scroll the highlighted range into view. + useEffect(() => { + if (!highlightRange || !editorRef || !editorRef.state) { + return; + } + + const doc = editorRef.state.doc; + const { start, end } = highlightRange; + const selection = EditorSelection.range( + doc.line(start.lineNumber).from, + doc.line(end.lineNumber).from, + ); + + editorRef.view?.dispatch({ + effects: [ + EditorView.scrollIntoView(selection, { y: "center" }), + ] + }); + }, [editorRef, highlightRange]); + + const onFindReferences = useCallback((symbolName: string) => { + captureEvent('wa_browse_find_references_pressed', {}); + + updateBrowseState({ + selectedSymbolInfo: { + repoName, + symbolName, + revisionName, + language, + }, + isBottomPanelCollapsed: false, + activeExploreMenuTab: "references", + }) + }, [captureEvent, updateBrowseState, repoName, revisionName, language]); + + + // If we resolve multiple matches, instead of navigating to the first match, we should + // instead popup the bottom sheet with the list of matches. + const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { + captureEvent('wa_browse_goto_definition_pressed', {}); + + if (symbolDefinitions.length === 0) { + return; + } + + if (symbolDefinitions.length === 1) { + const symbolDefinition = symbolDefinitions[0]; + const { fileName, repoName } = symbolDefinition; + + navigateToPath({ + repoName, + revisionName, + path: fileName, + pathType: 'blob', + highlightRange: symbolDefinition.range, + }) + } else { + updateBrowseState({ + selectedSymbolInfo: { + symbolName, + repoName, + revisionName, + language, + }, + activeExploreMenuTab: "definitions", + isBottomPanelCollapsed: false, + }) + } + }, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]); + + const theme = useCodeMirrorTheme(); + + return ( + + + {editorRef && editorRef.view && currentSelection && ( + + )} + {editorRef && hasCodeNavEntitlement && ( + + )} + + + + ) +} + diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx new file mode 100644 index 000000000..7f0788ea3 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { Loader2 } from "lucide-react"; +import { Separator } from "@/components/ui/separator"; +import { getRepoInfoByName } from "@/actions"; +import { FileHeader } from "@/app/[domain]/components/fileHeader"; +import { useCallback } from "react"; +import { FileTreeItem, getFolderContents } from "@/features/fileTree/actions"; +import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent"; +import { useBrowseNavigation } from "../../hooks/useBrowseNavigation"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { unwrapServiceError } from "@/lib/utils"; +import { useBrowseParams } from "../../hooks/useBrowseParams"; +import { useDomain } from "@/hooks/useDomain"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getFileSource } from "@/features/search/fileSourceApi"; + +export const TreePreviewPanel = () => { + const { path } = useBrowseParams(); + const { repoName, revisionName } = useBrowseParams(); + const domain = useDomain(); + const queryClient = useQueryClient(); + const { navigateToPath } = useBrowseNavigation(); + + const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({ + queryKey: ['repoInfo', repoName, domain], + queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)), + }); + + const { data, isPending: isFolderContentsPending, isError: isFolderContentsError } = useQuery({ + queryKey: ['tree', repoName, revisionName, path, domain], + queryFn: () => unwrapServiceError( + getFolderContents({ + repoName, + revisionName: revisionName ?? 'HEAD', + path, + }, domain) + ), + }); + + const onNodeClicked = useCallback((node: FileTreeItem) => { + navigateToPath({ + repoName: repoName, + revisionName: revisionName, + path: node.path, + pathType: node.type === 'tree' ? 'tree' : 'blob', + }); + }, [navigateToPath, repoName, revisionName]); + + const onNodeMouseEnter = useCallback((node: FileTreeItem) => { + if (node.type === 'blob') { + queryClient.prefetchQuery({ + queryKey: ['fileSource', repoName, revisionName, node.path, domain], + queryFn: () => unwrapServiceError(getFileSource({ + fileName: node.path, + repository: repoName, + branch: revisionName, + }, domain)), + }); + } else if (node.type === 'tree') { + queryClient.prefetchQuery({ + queryKey: ['tree', repoName, revisionName, node.path, domain], + queryFn: () => unwrapServiceError( + getFolderContents({ + repoName, + revisionName: revisionName ?? 'HEAD', + path: node.path, + }, domain) + ), + }); + } + + }, [queryClient, repoName, revisionName, domain]); + + if (isFolderContentsPending || isRepoInfoPending) { + return ( +
+ + Loading... +
+ ) + } + + if (isFolderContentsError || isRepoInfoError) { + return
Error loading tree
+ } + + return ( + <> +
+ +
+ + + {data.map((item) => ( + onNodeClicked(item)} + onMouseEnter={() => onNodeMouseEnter(item)} + /> + ))} + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index a61071e1e..3099f8161 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -1,99 +1,20 @@ 'use client'; -import { base64Decode, getCodeHostInfoForRepo, unwrapServiceError } from "@/lib/utils"; -import { CodePreviewPanel } from "./components/codePreviewPanel"; import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; -import { useQuery } from "@tanstack/react-query"; -import { getFileSource } from "@/features/search/fileSourceApi"; -import { useDomain } from "@/hooks/useDomain"; -import { Loader2 } from "lucide-react"; -import { Separator } from "@/components/ui/separator"; -import { getRepoInfoByName } from "@/actions"; -import { cn } from "@/lib/utils"; -import Image from "next/image"; -import { FileHeader } from "../../components/fileHeader"; -import { useMemo } from "react"; +import { CodePreviewPanel } from "./components/codePreviewPanel"; +import { TreePreviewPanel } from "./components/treePreviewPanel"; export default function BrowsePage() { - const { path, repoName, revisionName } = useBrowseParams(); - const domain = useDomain(); - - const { data: fileSourceResponse, isPending: isFileSourcePending, isError: isFileSourceError } = useQuery({ - queryKey: ['fileSource', repoName, revisionName, path, domain], - queryFn: () => unwrapServiceError(getFileSource({ - fileName: path, - repository: repoName, - branch: revisionName - }, domain)), - }); - - const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({ - queryKey: ['repoInfo', repoName, domain], - queryFn: () => unwrapServiceError(getRepoInfoByName(repoName, domain)), - }); - - const codeHostInfo = useMemo(() => { - if (!repoInfoResponse) { - return undefined; - } - - return getCodeHostInfoForRepo({ - codeHostType: repoInfoResponse.codeHostType, - name: repoInfoResponse.name, - displayName: repoInfoResponse.displayName, - webUrl: repoInfoResponse.webUrl, - }); - }, [repoInfoResponse]); - - if (isFileSourcePending || isRepoInfoPending) { - return ( -
- - Loading... -
- ) - } - - if (isFileSourceError || isRepoInfoError) { - return
error
- } - + const { pathType } = useBrowseParams(); return (
-
- - {(fileSourceResponse.webUrl && codeHostInfo) && ( - - {codeHostInfo.codeHostName} - Open in {codeHostInfo.codeHostName} - - )} -
- - + + {pathType === 'blob' ? ( + + ) : ( + + )}
) } + diff --git a/packages/web/src/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts index 6dd6a3521..a45c8039f 100644 --- a/packages/web/src/features/fileTree/actions.ts +++ b/packages/web/src/features/fileTree/actions.ts @@ -8,17 +8,24 @@ import { notFound } from '@/lib/serviceError'; import { simpleGit } from 'simple-git'; import path from 'path'; -export type FileTreeNode = { - name: string; - path: string; +export type FileTreeItem = { type: string; + path: string; + name: string; +} + +export type FileTreeNode = FileTreeItem & { children: FileTreeNode[]; } -export const getTree = async (repoName: string, revisionName: string, domain: string) => sew(() => +/** + * Returns the tree of files (blobs) and directories (trees) for a given repository, + * at a given revision. + */ +export const getTree = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() => withAuth((session) => withOrgMembership(session, domain, async ({ org }) => { - + const { repoName, revisionName } = params; const repo = await prisma.repo.findFirst({ where: { name: repoName, @@ -33,13 +40,15 @@ export const getTree = async (repoName: string, revisionName: string, domain: st const { path: repoPath } = getRepoPath(repo); const git = simpleGit().cwd(repoPath); - const result = await git.raw([ 'ls-tree', revisionName, + // recursive '-r', + // include trees when recursing '-t', - '--format=%(objecttype),%(path)' + // format as output as {type},{path} + '--format=%(objecttype),%(path)', ]); const lines = result.split('\n').filter(line => line.trim()); @@ -61,24 +70,62 @@ export const getTree = async (repoName: string, revisionName: string, domain: st }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) ); -const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } => { - // If we are dealing with a local repository, then use that as the path. - // Mark as read-only since we aren't guaranteed to have write access to the local filesystem. - const cloneUrl = new URL(repo.cloneUrl); - if (repo.external_codeHostType === 'generic-git-host' && cloneUrl.protocol === 'file:') { - return { - path: cloneUrl.pathname, - isReadOnly: true, - } - } +/** + * Returns the contents of a folder at a given path in a given repository, + * at a given revision. + */ +export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }, domain: string) => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async ({ org }) => { + const { repoName, revisionName, path } = params; + const repo = await prisma.repo.findFirst({ + where: { + name: repoName, + orgId: org.id, + }, + }); - const reposPath = path.join(env.DATA_CACHE_DIR, 'repos'); + if (!repo) { + return notFound(); + } - return { - path: path.join(reposPath, repo.id.toString()), - isReadOnly: false, - } -} + const { path: repoPath } = getRepoPath(repo); + + let normalizedPath = path; + + if (!normalizedPath.endsWith('/')) { + normalizedPath = `${normalizedPath}/`; + } + + if (normalizedPath.startsWith('/')) { + normalizedPath = normalizedPath.slice(1); + } + + const git = simpleGit().cwd(repoPath); + const result = await git.raw([ + 'ls-tree', + revisionName, + // format as output as {type},{path} + '--format=%(objecttype),%(path)', + ...(normalizedPath.length === 0 ? [] : [normalizedPath]), + ]); + + const lines = result.split('\n').filter(line => line.trim()); + + const contents: FileTreeItem[] = lines.map(line => { + const [type, path] = line.split(','); + const name = path.split('/').pop() ?? ''; + + return { + type, + path, + name, + } + }); + + return contents; + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true) +) const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const root: FileTreeNode = { @@ -132,4 +179,26 @@ const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode }; return sortTree(root); -} \ No newline at end of file +} + +// @todo: this is duplicated from the `getRepoPath` function in the +// backend's `utils.ts` file. Eventually we should move this to a shared +// package. +const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } => { + // If we are dealing with a local repository, then use that as the path. + // Mark as read-only since we aren't guaranteed to have write access to the local filesystem. + const cloneUrl = new URL(repo.cloneUrl); + if (repo.external_codeHostType === 'generic-git-host' && cloneUrl.protocol === 'file:') { + return { + path: cloneUrl.pathname, + isReadOnly: true, + } + } + + const reposPath = path.join(env.DATA_CACHE_DIR, 'repos'); + + return { + path: path.join(reposPath, repo.id.toString()), + isReadOnly: false, + } +} diff --git a/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx b/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx new file mode 100644 index 000000000..a3ffe5f10 --- /dev/null +++ b/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { FileTreeItem } from "../actions"; +import { useMemo, useEffect, useRef } from "react"; +import { getIconForFile, getIconForFolder } from "vscode-icons-js"; +import { Icon } from '@iconify/react'; +import clsx from "clsx"; +import scrollIntoView from 'scroll-into-view-if-needed'; +import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; + +export const FileTreeItemComponent = ({ + node, + isActive, + depth, + isCollapsed = false, + isCollapseChevronVisible = true, + onClick, + onMouseEnter, +}: { + node: FileTreeItem, + isActive: boolean, + depth: number, + isCollapsed?: boolean, + isCollapseChevronVisible?: boolean, + onClick: () => void, + onMouseEnter: () => void, +}) => { + const ref = useRef(null); + + useEffect(() => { + if (isActive && ref.current) { + scrollIntoView(ref.current, { + scrollMode: 'if-needed', + block: 'center', + behavior: 'instant', + }); + } + }, [isActive]); + + const iconName = useMemo(() => { + if (node.type === 'tree') { + const icon = getIconForFolder(node.name); + if (icon) { + const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; + return iconName; + } + } else if (node.type === 'blob') { + const icon = getIconForFile(node.name); + if (icon) { + const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; + return iconName; + } + } + + return "vscode-icons:file-type-unknown"; + }, [node.name, node.type]); + + return ( +
{ + if (e.key === 'Enter') { + e.preventDefault(); + onClick(); + } + }} + onClick={onClick} + onMouseEnter={onMouseEnter} + > + {isCollapseChevronVisible && ( +
+ {isCollapsed ? ( + + ) : ( + + )} +
+ )} + + {node.name} +
+ ) +} diff --git a/packages/web/src/features/fileTree/components/fileTreePanel.tsx b/packages/web/src/features/fileTree/components/fileTreePanel.tsx index faedd3815..dd16b3e02 100644 --- a/packages/web/src/features/fileTree/components/fileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/fileTreePanel.tsx @@ -39,14 +39,19 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { }, updateBrowseState, } = useBrowseState(); - + const domain = useDomain(); const { repoName, revisionName, path } = useBrowseParams(); const fileTreePanelRef = useRef(null); const { data, isPending, isError } = useQuery({ queryKey: ['tree', repoName, revisionName, domain], - queryFn: () => unwrapServiceError(getTree(repoName, revisionName ?? 'HEAD', domain)), + queryFn: () => unwrapServiceError( + getTree({ + repoName, + revisionName: revisionName ?? 'HEAD', + }, domain) + ), }); useHotkeys("mod+b", () => { @@ -110,8 +115,6 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { ) : ( )} diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx index 56ef53608..e4dc30cc3 100644 --- a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -3,16 +3,13 @@ import { FileTreeNode as RawFileTreeNode } from "../actions"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { useCallback, useMemo, useState, useEffect, useRef } from "react"; -import { getIconForFile, getIconForFolder } from "vscode-icons-js"; -import { Icon } from '@iconify/react'; +import { FileTreeItemComponent } from "./fileTreeItemComponent"; import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; -import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; -import clsx from "clsx"; +import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; import { useQueryClient } from "@tanstack/react-query"; import { useDomain } from "@/hooks/useDomain"; import { unwrapServiceError } from "@/lib/utils"; import { getFileSource } from "@/features/search/fileSourceApi"; -import scrollIntoView from 'scroll-into-view-if-needed' export type FileTreeNode = Omit & { @@ -42,17 +39,16 @@ const transformTree = ( interface PureFileTreePanelProps { tree: RawFileTreeNode; - repoName: string; - revisionName: string; path: string; } -export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: PureFileTreePanelProps) => { +export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) => { const [tree, setTree] = useState(buildCollapsableTree(_tree)); const scrollAreaRef = useRef(null); + const { navigateToPath } = useBrowseNavigation(); + const { repoName, revisionName } = useBrowseParams(); const queryClient = useQueryClient(); const domain = useDomain(); - const { navigateToPath } = useBrowseNavigation(); // @note: When `_tree` changes, it indicates that a new tree has been loaded. // In that case, we need to rebuild the collapsable tree. @@ -60,10 +56,6 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: setTree(buildCollapsableTree(_tree)); }, [_tree]); - useEffect(() => { - console.log(repoName, revisionName, path, tree.children[0].path); - }, [tree, repoName, revisionName, path]); - const setIsCollapsed = useCallback((path: string, isCollapsed: boolean) => { setTree(currentTree => transformTree(currentTree, (currentNode) => { if (currentNode.path === path) { @@ -126,21 +118,23 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }:
{nodes.children.map((node) => { return ( - <> - + onNodeClicked(node)} + onMouseEnter={() => onNodeMouseEnter(node)} /> {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} - +
); })}
); - }, [onNodeClicked, onNodeMouseEnter, path]); + }, [path, onNodeClicked, onNodeMouseEnter]); const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); @@ -155,77 +149,3 @@ export const PureFileTreePanel = ({ tree: _tree, repoName, revisionName, path }: ) } -const FileTreeItem = ({ - node, - isActive, - depth, - onNodeClicked, - onNodeMouseEnter, -}: { - node: FileTreeNode, - isActive: boolean, - depth: number, - onNodeClicked: (node: FileTreeNode) => void, - onNodeMouseEnter: (node: FileTreeNode) => void, -}) => { - const ref = useRef(null); - - useEffect(() => { - if (isActive && ref.current) { - scrollIntoView(ref.current, { - scrollMode: 'if-needed', - block: 'center', - behavior: 'instant', - }); - } - }, [isActive]); - - const iconName = useMemo(() => { - if (node.type === 'tree') { - const icon = getIconForFolder(node.name); - if (icon) { - const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; - return iconName; - } - } else if (node.type === 'blob') { - const icon = getIconForFile(node.name); - if (icon) { - const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; - return iconName; - } - } - - return "vscode-icons:file-type-unknown"; - }, [node.name, node.type]); - - return ( -
{ - if (e.key === 'Enter') { - e.preventDefault(); - onNodeClicked(node); - } - }} - onClick={() => onNodeClicked(node)} - onMouseEnter={() => onNodeMouseEnter(node)} - > -
- {node.type === 'tree' && ( - node.isCollapsed ? ( - - ) : ( - - ) - )} -
- - {node.name} -
- ) -} From 3402d6d3704e9974c67afe18933acc15b0d62a16 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 5 Jun 2025 18:12:19 -0700 Subject: [PATCH 12/18] breadcrumb system --- .../[...path]/components/codePreviewPanel.tsx | 6 +- .../[...path]/components/treePreviewPanel.tsx | 7 +- .../app/[domain]/components/fileHeader.tsx | 125 ------------ .../app/[domain]/components/pathHeader.tsx | 193 ++++++++++++++++++ .../searchResultsPanel/fileMatchContainer.tsx | 8 +- .../components/exploreMenu/referenceList.tsx | 6 +- 6 files changed, 207 insertions(+), 138 deletions(-) delete mode 100644 packages/web/src/app/[domain]/components/fileHeader.tsx create mode 100644 packages/web/src/app/[domain]/components/pathHeader.tsx diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx index 7608539c9..13c32a62d 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx @@ -12,7 +12,7 @@ import { cn } from "@/lib/utils"; import Image from "next/image"; import { useMemo } from "react"; import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; -import { FileHeader } from "@/app/[domain]/components/fileHeader"; +import { PathHeader } from "@/app/[domain]/components/pathHeader"; export const CodePreviewPanel = () => { const { path, repoName, revisionName } = useBrowseParams(); @@ -61,8 +61,8 @@ export const CodePreviewPanel = () => { return ( <>
- { return ( <>
-
diff --git a/packages/web/src/app/[domain]/components/fileHeader.tsx b/packages/web/src/app/[domain]/components/fileHeader.tsx deleted file mode 100644 index 949c345a0..000000000 --- a/packages/web/src/app/[domain]/components/fileHeader.tsx +++ /dev/null @@ -1,125 +0,0 @@ -'use client'; - -import { getCodeHostInfoForRepo } from "@/lib/utils"; -import { LaptopIcon } from "@radix-ui/react-icons"; -import clsx from "clsx"; -import Image from "next/image"; -import Link from "next/link"; -import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation"; -import { Copy, CheckCircle2 } from "lucide-react"; -import { useState } from "react"; -import { useToast } from "@/components/hooks/use-toast"; - -interface FileHeaderProps { - fileName: string; - fileNameHighlightRange?: { - from: number; - to: number; - } - repo: { - name: string; - codeHostType: string; - displayName?: string; - webUrl?: string; - }, - branchDisplayName?: string; - branchDisplayTitle?: string; -} - -export const FileHeader = ({ - repo, - fileName, - fileNameHighlightRange, - branchDisplayName, - branchDisplayTitle, -}: FileHeaderProps) => { - const info = getCodeHostInfoForRepo({ - name: repo.name, - codeHostType: repo.codeHostType, - displayName: repo.displayName, - webUrl: repo.webUrl, - }); - - const { navigateToPath } = useBrowseNavigation(); - const { toast } = useToast(); - const [copied, setCopied] = useState(false); - - const handleCopy = () => { - navigator.clipboard.writeText(fileName); - setCopied(true); - toast({ description: "Copied file path!" }); - setTimeout(() => setCopied(false), 1500); - }; - - return ( -
- {info?.icon ? ( - {info.codeHostName} - ): ( - - )} - - {info?.displayName} - - {branchDisplayName && ( -

- @ - {`${branchDisplayName}`} -

- )} - · -
- { - navigateToPath({ - repoName: repo.name, - path: fileName, - pathType: 'blob', - revisionName: branchDisplayName, - }); - }} - > - {!fileNameHighlightRange ? - fileName - : ( - <> - {fileName.slice(0, fileNameHighlightRange.from)} - - {fileName.slice(fileNameHighlightRange.from, fileNameHighlightRange.to)} - - {fileName.slice(fileNameHighlightRange.to)} - - )} - - -
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/pathHeader.tsx b/packages/web/src/app/[domain]/components/pathHeader.tsx new file mode 100644 index 000000000..e61288e37 --- /dev/null +++ b/packages/web/src/app/[domain]/components/pathHeader.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { getCodeHostInfoForRepo } from "@/lib/utils"; +import { LaptopIcon } from "@radix-ui/react-icons"; +import clsx from "clsx"; +import Image from "next/image"; +import Link from "next/link"; +import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation"; +import { Copy, CheckCircle2, ChevronRight } from "lucide-react"; +import { useCallback, useState, useMemo } from "react"; +import { useToast } from "@/components/hooks/use-toast"; + +interface FileHeaderProps { + path: string; + pathHighlightRange?: { + from: number; + to: number; + } + pathType?: 'blob' | 'tree'; + repo: { + name: string; + codeHostType: string; + displayName?: string; + webUrl?: string; + }, + branchDisplayName?: string; + branchDisplayTitle?: string; +} + +interface BreadcrumbSegment { + name: string; + fullPath: string; + isLastSegment: boolean; + highlightRange?: { + from: number; + to: number; + }; +} + +export const PathHeader = ({ + repo, + path, + pathHighlightRange, + branchDisplayName, + branchDisplayTitle, + pathType = 'blob', +}: FileHeaderProps) => { + const info = getCodeHostInfoForRepo({ + name: repo.name, + codeHostType: repo.codeHostType, + displayName: repo.displayName, + webUrl: repo.webUrl, + }); + + const { navigateToPath } = useBrowseNavigation(); + const { toast } = useToast(); + const [copied, setCopied] = useState(false); + + // Create breadcrumb segments from file path + const breadcrumbSegments = useMemo(() => { + const pathParts = path.split('/').filter(Boolean); + const segments: BreadcrumbSegment[] = []; + + let currentPath = ''; + pathParts.forEach((part, index) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + const isLastSegment = index === pathParts.length - 1; + + // Calculate highlight range for this segment if it exists + let segmentHighlight: { from: number; to: number } | undefined; + if (pathHighlightRange) { + const segmentStart = path.indexOf(part, currentPath.length - part.length); + const segmentEnd = segmentStart + part.length; + + // Check if highlight overlaps with this segment + if (pathHighlightRange.from < segmentEnd && pathHighlightRange.to > segmentStart) { + segmentHighlight = { + from: Math.max(0, pathHighlightRange.from - segmentStart), + to: Math.min(part.length, pathHighlightRange.to - segmentStart) + }; + } + } + + segments.push({ + name: part, + fullPath: currentPath, + isLastSegment, + highlightRange: segmentHighlight + }); + }); + + return segments; + }, [path, pathHighlightRange]); + + const onCopyPath = useCallback(() => { + navigator.clipboard.writeText(path); + setCopied(true); + toast({ description: "✅ Copied to clipboard" }); + setTimeout(() => setCopied(false), 1500); + }, [path, toast]); + + const onBreadcrumbClick = useCallback((segment: BreadcrumbSegment) => { + navigateToPath({ + repoName: repo.name, + path: segment.fullPath, + pathType: segment.isLastSegment ? pathType : 'tree', + revisionName: branchDisplayName, + }); + }, [repo.name, branchDisplayName, navigateToPath, pathType]); + + const renderSegmentWithHighlight = (segment: BreadcrumbSegment) => { + if (!segment.highlightRange) { + return segment.name; + } + + const { from, to } = segment.highlightRange; + return ( + <> + {segment.name.slice(0, from)} + + {segment.name.slice(from, to)} + + {segment.name.slice(to)} + + ); + }; + + return ( +
+ {info?.icon ? ( + {info.codeHostName} + ): ( + + )} + + {info?.displayName} + + {branchDisplayName && ( +

+ @ + {`${branchDisplayName}`} +

+ )} + · +
+
+ {breadcrumbSegments.map((segment, index) => ( +
+ onBreadcrumbClick(segment)} + > + {renderSegmentWithHighlight(segment)} + + {index < breadcrumbSegments.length - 1 && ( + + )} +
+ ))} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx index c179aaccf..820521b9f 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FileHeader } from "@/app/[domain]/components/fileHeader"; +import { PathHeader } from "@/app/[domain]/components/pathHeader"; import { Separator } from "@/components/ui/separator"; import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons"; import { useMemo } from "react"; @@ -92,15 +92,15 @@ export const FileMatchContainer = ({ top: `-${yOffset}px`, }} > - diff --git a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx index fc5de453b..22959b643 100644 --- a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx +++ b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx @@ -1,7 +1,7 @@ 'use client'; import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; -import { FileHeader } from "@/app/[domain]/components/fileHeader"; +import { PathHeader } from "@/app/[domain]/components/pathHeader"; import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; import { FindRelatedSymbolsResponse } from "@/features/codeNav/types"; import { RepositoryInfo, SourceRange } from "@/features/search/types"; @@ -93,14 +93,14 @@ export const ReferenceList = ({ top: `-${virtualRow.start}px`, }} > -
From b6867d2585c393e2cdbdbe7ad69660f74fd2b8b9 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 5 Jun 2025 22:42:09 -0700 Subject: [PATCH 13/18] fix scroll issue. Put re-used prefetching logic into shared hooks --- .../[...path]/components/treePreviewPanel.tsx | 40 +++++++----------- .../app/[domain]/components/pathHeader.tsx | 21 +++++++++- .../components/exploreMenu/referenceList.tsx | 19 +++------ .../components/fileTreeItemComponent.tsx | 42 +++++++++++++------ .../fileTree/components/pureFileTreePanel.tsx | 30 +++++-------- .../web/src/hooks/usePrefetchFileSource.ts | 25 +++++++++++ .../src/hooks/usePrefetchFolderContents.ts | 27 ++++++++++++ 7 files changed, 133 insertions(+), 71 deletions(-) create mode 100644 packages/web/src/hooks/usePrefetchFileSource.ts create mode 100644 packages/web/src/hooks/usePrefetchFolderContents.ts diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx index d508a282e..33336ad3f 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx @@ -4,7 +4,7 @@ import { Loader2 } from "lucide-react"; import { Separator } from "@/components/ui/separator"; import { getRepoInfoByName } from "@/actions"; import { PathHeader } from "@/app/[domain]/components/pathHeader"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { FileTreeItem, getFolderContents } from "@/features/fileTree/actions"; import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent"; import { useBrowseNavigation } from "../../hooks/useBrowseNavigation"; @@ -12,15 +12,18 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { unwrapServiceError } from "@/lib/utils"; import { useBrowseParams } from "../../hooks/useBrowseParams"; import { useDomain } from "@/hooks/useDomain"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { getFileSource } from "@/features/search/fileSourceApi"; +import { useQuery } from "@tanstack/react-query"; +import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource"; +import { usePrefetchFolderContents } from "@/hooks/usePrefetchFolderContents"; export const TreePreviewPanel = () => { const { path } = useBrowseParams(); const { repoName, revisionName } = useBrowseParams(); const domain = useDomain(); - const queryClient = useQueryClient(); const { navigateToPath } = useBrowseNavigation(); + const { prefetchFileSource } = usePrefetchFileSource(); + const { prefetchFolderContents } = usePrefetchFolderContents(); + const scrollAreaRef = useRef(null); const { data: repoInfoResponse, isPending: isRepoInfoPending, isError: isRepoInfoError } = useQuery({ queryKey: ['repoInfo', repoName, domain], @@ -49,28 +52,11 @@ export const TreePreviewPanel = () => { const onNodeMouseEnter = useCallback((node: FileTreeItem) => { if (node.type === 'blob') { - queryClient.prefetchQuery({ - queryKey: ['fileSource', repoName, revisionName, node.path, domain], - queryFn: () => unwrapServiceError(getFileSource({ - fileName: node.path, - repository: repoName, - branch: revisionName, - }, domain)), - }); + prefetchFileSource(repoName, revisionName ?? 'HEAD', node.path); } else if (node.type === 'tree') { - queryClient.prefetchQuery({ - queryKey: ['tree', repoName, revisionName, node.path, domain], - queryFn: () => unwrapServiceError( - getFolderContents({ - repoName, - revisionName: revisionName ?? 'HEAD', - path: node.path, - }, domain) - ), - }); + prefetchFolderContents(repoName, revisionName ?? 'HEAD', node.path); } - - }, [queryClient, repoName, revisionName, domain]); + }, [prefetchFileSource, prefetchFolderContents, repoName, revisionName]); if (isFolderContentsPending || isRepoInfoPending) { return ( @@ -100,7 +86,10 @@ export const TreePreviewPanel = () => { />
- + {data.map((item) => ( { isCollapseChevronVisible={false} onClick={() => onNodeClicked(item)} onMouseEnter={() => onNodeMouseEnter(item)} + parentRef={scrollAreaRef} /> ))} diff --git a/packages/web/src/app/[domain]/components/pathHeader.tsx b/packages/web/src/app/[domain]/components/pathHeader.tsx index e61288e37..8b4b1a7da 100644 --- a/packages/web/src/app/[domain]/components/pathHeader.tsx +++ b/packages/web/src/app/[domain]/components/pathHeader.tsx @@ -9,6 +9,8 @@ import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation"; import { Copy, CheckCircle2, ChevronRight } from "lucide-react"; import { useCallback, useState, useMemo } from "react"; import { useToast } from "@/components/hooks/use-toast"; +import { usePrefetchFolderContents } from "@/hooks/usePrefetchFolderContents"; +import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource"; interface FileHeaderProps { path: string; @@ -55,7 +57,9 @@ export const PathHeader = ({ const { navigateToPath } = useBrowseNavigation(); const { toast } = useToast(); const [copied, setCopied] = useState(false); - + const { prefetchFolderContents } = usePrefetchFolderContents(); + const { prefetchFileSource } = usePrefetchFileSource(); + // Create breadcrumb segments from file path const breadcrumbSegments = useMemo(() => { const pathParts = path.split('/').filter(Boolean); @@ -108,6 +112,20 @@ export const PathHeader = ({ }); }, [repo.name, branchDisplayName, navigateToPath, pathType]); + const onBreadcrumbMouseEnter = useCallback((segment: BreadcrumbSegment) => { + if (segment.isLastSegment && pathType === 'blob') { + prefetchFileSource(repo.name, branchDisplayName ?? 'HEAD', segment.fullPath); + } else { + prefetchFolderContents(repo.name, branchDisplayName ?? 'HEAD', segment.fullPath); + } + }, [ + repo.name, + branchDisplayName, + prefetchFolderContents, + pathType, + prefetchFileSource, + ]); + const renderSegmentWithHighlight = (segment: BreadcrumbSegment) => { if (!segment.highlightRange) { return segment.name; @@ -166,6 +184,7 @@ export const PathHeader = ({ "font-mono text-sm truncate cursor-pointer hover:underline", )} onClick={() => onBreadcrumbClick(segment)} + onMouseEnter={() => onBreadcrumbMouseEnter(segment)} > {renderSegmentWithHighlight(segment)} diff --git a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx index 22959b643..6fc445da6 100644 --- a/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx +++ b/packages/web/src/ee/features/codeNav/components/exploreMenu/referenceList.tsx @@ -5,13 +5,12 @@ import { PathHeader } from "@/app/[domain]/components/pathHeader"; import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; import { FindRelatedSymbolsResponse } from "@/features/codeNav/types"; import { RepositoryInfo, SourceRange } from "@/features/search/types"; -import { base64Decode, unwrapServiceError } from "@/lib/utils"; +import { base64Decode } from "@/lib/utils"; import { useMemo, useRef } from "react"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useQueryClient } from "@tanstack/react-query"; -import { getFileSource } from "@/features/search/fileSourceApi"; -import { useDomain } from "@/hooks/useDomain"; +import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource"; + interface ReferenceListProps { data: FindRelatedSymbolsResponse; revisionName: string; @@ -33,8 +32,7 @@ export const ReferenceList = ({ const { navigateToPath } = useBrowseNavigation(); const captureEvent = useCaptureEvent(); - const queryClient = useQueryClient(); - const domain = useDomain(); + const { prefetchFileSource } = usePrefetchFileSource(); // Virtualization setup const parentRef = useRef(null); @@ -128,14 +126,7 @@ export const ReferenceList = ({ // the user clicks on a file to open it. // @see: /browse/[...path]/page.tsx onMouseEnter={() => { - queryClient.prefetchQuery({ - queryKey: ['fileSource', file.repository, revisionName, file.fileName, domain], - queryFn: () => unwrapServiceError(getFileSource({ - fileName: file.fileName, - repository: file.repository, - branch: revisionName, - }, domain)), - }); + prefetchFileSource(file.repository, revisionName, file.fileName); }} /> ))} diff --git a/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx b/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx index a3ffe5f10..8dc856a9c 100644 --- a/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx +++ b/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx @@ -16,6 +16,7 @@ export const FileTreeItemComponent = ({ isCollapseChevronVisible = true, onClick, onMouseEnter, + parentRef, }: { node: FileTreeItem, isActive: boolean, @@ -24,6 +25,7 @@ export const FileTreeItemComponent = ({ isCollapseChevronVisible?: boolean, onClick: () => void, onMouseEnter: () => void, + parentRef: React.RefObject, }) => { const ref = useRef(null); @@ -33,9 +35,23 @@ export const FileTreeItemComponent = ({ scrollMode: 'if-needed', block: 'center', behavior: 'instant', + // We only want to scroll if the element is hidden vertically + // in the parent element. + boundary: () => { + if (!parentRef.current || !ref.current) { + return false; + } + + const rect = ref.current.getBoundingClientRect(); + const parentRect = parentRef.current.getBoundingClientRect(); + + const completelyAbove = rect.bottom <= parentRect.top; + const completelyBelow = rect.top >= parentRect.bottom; + return completelyAbove || completelyBelow; + } }); } - }, [isActive]); + }, [isActive, parentRef]); const iconName = useMemo(() => { if (node.type === 'tree') { @@ -72,17 +88,19 @@ export const FileTreeItemComponent = ({ onClick={onClick} onMouseEnter={onMouseEnter} > - {isCollapseChevronVisible && ( -
- {isCollapsed ? ( - - ) : ( - - )} -
- )} +
+ {isCollapseChevronVisible && ( + <> + {isCollapsed ? ( + + ) : ( + + )} + + )} +
{node.name}
diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx index e4dc30cc3..a6c73cd30 100644 --- a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -6,10 +6,8 @@ import { useCallback, useMemo, useState, useEffect, useRef } from "react"; import { FileTreeItemComponent } from "./fileTreeItemComponent"; import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; -import { useQueryClient } from "@tanstack/react-query"; import { useDomain } from "@/hooks/useDomain"; -import { unwrapServiceError } from "@/lib/utils"; -import { getFileSource } from "@/features/search/fileSourceApi"; +import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource"; export type FileTreeNode = Omit & { @@ -47,7 +45,7 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) const scrollAreaRef = useRef(null); const { navigateToPath } = useBrowseNavigation(); const { repoName, revisionName } = useBrowseParams(); - const queryClient = useQueryClient(); + const { prefetchFileSource } = usePrefetchFileSource(); const domain = useDomain(); // @note: When `_tree` changes, it indicates that a new tree has been loaded. @@ -102,24 +100,17 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) return; } - queryClient.prefetchQuery({ - queryKey: ['fileSource', repoName, revisionName, node.path, domain], - queryFn: () => unwrapServiceError(getFileSource({ - fileName: node.path, - repository: repoName, - branch: revisionName, - }, domain)), - }); + prefetchFileSource(repoName, revisionName ?? 'HEAD', node.path); + }, [prefetchFileSource, repoName, revisionName]); - }, [queryClient, repoName, revisionName, domain]); - - const renderTree = useCallback((nodes: FileTreeNode, depth = 0) => { + const renderTree = useCallback((nodes: FileTreeNode, depth = 0): React.ReactNode => { return ( -
+ <> {nodes.children.map((node) => { return ( -
+ <> onNodeClicked(node)} onMouseEnter={() => onNodeMouseEnter(node)} + parentRef={scrollAreaRef} /> {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} -
+ ); })} -
+ ); }, [path, onNodeClicked, onNodeMouseEnter]); diff --git a/packages/web/src/hooks/usePrefetchFileSource.ts b/packages/web/src/hooks/usePrefetchFileSource.ts new file mode 100644 index 000000000..755d7fdb9 --- /dev/null +++ b/packages/web/src/hooks/usePrefetchFileSource.ts @@ -0,0 +1,25 @@ +'use client'; + +import { useQueryClient } from "@tanstack/react-query"; +import { useDomain } from "./useDomain"; +import { unwrapServiceError } from "@/lib/utils"; +import { getFileSource } from "@/features/search/fileSourceApi"; +import { useCallback } from "react"; + +export const usePrefetchFileSource = () => { + const queryClient = useQueryClient(); + const domain = useDomain(); + + const prefetchFileSource = useCallback((repoName: string, revisionName: string, path: string) => { + queryClient.prefetchQuery({ + queryKey: ['fileSource', repoName, revisionName, path, domain], + queryFn: () => unwrapServiceError(getFileSource({ + fileName: path, + repository: repoName, + branch: revisionName, + }, domain)), + }); + }, [queryClient, domain]); + + return { prefetchFileSource }; +} \ No newline at end of file diff --git a/packages/web/src/hooks/usePrefetchFolderContents.ts b/packages/web/src/hooks/usePrefetchFolderContents.ts new file mode 100644 index 000000000..8bbcc5f8d --- /dev/null +++ b/packages/web/src/hooks/usePrefetchFolderContents.ts @@ -0,0 +1,27 @@ +'use client'; + +import { useQueryClient } from "@tanstack/react-query"; +import { useDomain } from "./useDomain"; +import { unwrapServiceError } from "@/lib/utils"; +import { useCallback } from "react"; +import { getFolderContents } from "@/features/fileTree/actions"; + +export const usePrefetchFolderContents = () => { + const queryClient = useQueryClient(); + const domain = useDomain(); + + const prefetchFolderContents = useCallback((repoName: string, revisionName: string, path: string) => { + queryClient.prefetchQuery({ + queryKey: ['tree', repoName, revisionName, path, domain], + queryFn: () => unwrapServiceError( + getFolderContents({ + repoName, + revisionName, + path, + }, domain) + ), + }); + }, [queryClient, domain]); + + return { prefetchFolderContents }; +} \ No newline at end of file From 4aca32e4af0c106c511fc45ef4a10d33b3190f00 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 5 Jun 2025 22:50:11 -0700 Subject: [PATCH 14/18] fix issue with paths that have spaces --- .../web/src/app/[domain]/browse/hooks/useBrowseParams.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts index 3ca3392b7..b671d3fcf 100644 --- a/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts @@ -23,15 +23,17 @@ export const useBrowseParams = () => { const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => { const path = rawPath.substring(sentinalIndex + '/-/'.length); const pathType = path.startsWith('tree/') ? 'tree' : 'blob'; + + // @note: decodedURIComponent is needed here incase the path contains a space. switch (pathType) { case 'tree': return { - path: path.substring('tree/'.length), + path: decodeURIComponent(path.substring('tree/'.length)), pathType, }; case 'blob': return { - path: path.substring('blob/'.length), + path: decodeURIComponent(path.substring('blob/'.length)), pathType, }; } From c2d8ec8a1b4e434d8669136c7ab738ba6353afdb Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 6 Jun 2025 10:40:55 -0700 Subject: [PATCH 15/18] Make breadcumb paths responsive by collapsing long paths with an ellipses. Clicking on the ellipses reveales a dropdown menu with the hidden path parts. --- .../app/[domain]/components/pathHeader.tsx | 116 +++++++++++++++++- 1 file changed, 110 insertions(+), 6 deletions(-) diff --git a/packages/web/src/app/[domain]/components/pathHeader.tsx b/packages/web/src/app/[domain]/components/pathHeader.tsx index 8b4b1a7da..801979f7d 100644 --- a/packages/web/src/app/[domain]/components/pathHeader.tsx +++ b/packages/web/src/app/[domain]/components/pathHeader.tsx @@ -6,11 +6,17 @@ import clsx from "clsx"; import Image from "next/image"; import Link from "next/link"; import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation"; -import { Copy, CheckCircle2, ChevronRight } from "lucide-react"; -import { useCallback, useState, useMemo } from "react"; +import { Copy, CheckCircle2, ChevronRight, MoreHorizontal } from "lucide-react"; +import { useCallback, useState, useMemo, useRef, useEffect } from "react"; import { useToast } from "@/components/hooks/use-toast"; import { usePrefetchFolderContents } from "@/hooks/usePrefetchFolderContents"; import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; interface FileHeaderProps { path: string; @@ -60,6 +66,10 @@ export const PathHeader = ({ const { prefetchFolderContents } = usePrefetchFolderContents(); const { prefetchFileSource } = usePrefetchFileSource(); + const containerRef = useRef(null); + const breadcrumbsRef = useRef(null); + const [visibleSegmentCount, setVisibleSegmentCount] = useState(null); + // Create breadcrumb segments from file path const breadcrumbSegments = useMemo(() => { const pathParts = path.split('/').filter(Boolean); @@ -96,6 +106,73 @@ export const PathHeader = ({ return segments; }, [path, pathHighlightRange]); + // Calculate which segments should be visible based on available space + useEffect(() => { + const measureSegments = () => { + if (!containerRef.current || !breadcrumbsRef.current) return; + + const containerWidth = containerRef.current.offsetWidth; + const availableWidth = containerWidth - 175; // Reserve space for copy button and padding + + // Create a temporary element to measure segment widths + const tempElement = document.createElement('div'); + tempElement.style.position = 'absolute'; + tempElement.style.visibility = 'hidden'; + tempElement.style.whiteSpace = 'nowrap'; + tempElement.className = 'font-mono text-sm'; + document.body.appendChild(tempElement); + + let totalWidth = 0; + let visibleCount = breadcrumbSegments.length; + + // Start from the end (most important segments) and work backwards + for (let i = breadcrumbSegments.length - 1; i >= 0; i--) { + const segment = breadcrumbSegments[i]; + tempElement.textContent = segment.name; + const segmentWidth = tempElement.offsetWidth; + const separatorWidth = i < breadcrumbSegments.length - 1 ? 16 : 0; // ChevronRight width + + if (totalWidth + segmentWidth + separatorWidth > availableWidth && i > 0) { + // If adding this segment would overflow and it's not the last segment + visibleCount = breadcrumbSegments.length - i; + // Add width for ellipsis dropdown (approximately 24px) + if (visibleCount < breadcrumbSegments.length) { + totalWidth += 40; // Ellipsis button + separator + } + break; + } + + totalWidth += segmentWidth + separatorWidth; + } + + document.body.removeChild(tempElement); + setVisibleSegmentCount(visibleCount); + }; + + measureSegments(); + + const resizeObserver = new ResizeObserver(measureSegments); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => resizeObserver.disconnect(); + }, [breadcrumbSegments]); + + const hiddenSegments = useMemo(() => { + if (visibleSegmentCount === null || visibleSegmentCount >= breadcrumbSegments.length) { + return []; + } + return breadcrumbSegments.slice(0, breadcrumbSegments.length - visibleSegmentCount); + }, [breadcrumbSegments, visibleSegmentCount]); + + const visibleSegments = useMemo(() => { + if (visibleSegmentCount === null) { + return breadcrumbSegments; + } + return breadcrumbSegments.slice(breadcrumbSegments.length - visibleSegmentCount); + }, [breadcrumbSegments, visibleSegmentCount]); + const onCopyPath = useCallback(() => { navigator.clipboard.writeText(path); setCopied(true); @@ -175,9 +252,36 @@ export const PathHeader = ({

)} · -
-
- {breadcrumbSegments.map((segment, index) => ( +
+
+ {hiddenSegments.length > 0 && ( + <> + + + + + + {hiddenSegments.map((segment) => ( + onBreadcrumbClick(segment)} + onMouseEnter={() => onBreadcrumbMouseEnter(segment)} + className="font-mono text-sm cursor-pointer" + > + {renderSegmentWithHighlight(segment)} + + ))} + + + + + )} + {visibleSegments.map((segment, index) => (
{renderSegmentWithHighlight(segment)} - {index < breadcrumbSegments.length - 1 && ( + {index < visibleSegments.length - 1 && ( )}
From a6b3f2d17af969dad17aa37db08a8327a2812b16 Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 6 Jun 2025 10:43:14 -0700 Subject: [PATCH 16/18] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eac726646..4f593c1c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added seperate page for signup. [#311](https://github.com/sourcebot-dev/sourcebot/pull/331) - Fix repo images in authed instance case and add manifest json. [#332](https://github.com/sourcebot-dev/sourcebot/pull/332) - Added encryption logic for license keys. [#335](https://github.com/sourcebot-dev/sourcebot/pull/335) +- Added support for a file explorer when browsing files. [#336](https://github.com/sourcebot-dev/sourcebot/pull/336) ## [4.1.1] - 2025-06-03 From 2b8fce250b18cfb724a5b5d117cc5f5018e0b55b Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 6 Jun 2025 10:44:50 -0700 Subject: [PATCH 17/18] Add news data --- packages/web/src/lib/newsData.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/web/src/lib/newsData.ts b/packages/web/src/lib/newsData.ts index 48ecdcf08..fe0701366 100644 --- a/packages/web/src/lib/newsData.ts +++ b/packages/web/src/lib/newsData.ts @@ -1,6 +1,12 @@ import { NewsItem } from "./types"; export const newsData: NewsItem[] = [ + { + unique_id: "file-explorer", + header: "File explorer", + sub_header: "We've added support for a file explorer when browsing files.", + url: "https://github.com/sourcebot-dev/sourcebot/releases/tag/v4.2.0" + }, { unique_id: "structured-logging", header: "Structured logging", From 3b1403299e9f6cc974bfb7bbd3b2d88928f912cf Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 6 Jun 2025 12:27:02 -0700 Subject: [PATCH 18/18] feedback --- packages/web/src/features/fileTree/actions.ts | 66 ++++++++++++++----- .../components/fileTreeItemComponent.tsx | 12 ++-- .../fileTree/components/fileTreePanel.tsx | 26 ++++---- .../fileTree/components/pureFileTreePanel.tsx | 8 +-- 4 files changed, 69 insertions(+), 43 deletions(-) diff --git a/packages/web/src/features/fileTree/actions.ts b/packages/web/src/features/fileTree/actions.ts index a45c8039f..cc91a89e2 100644 --- a/packages/web/src/features/fileTree/actions.ts +++ b/packages/web/src/features/fileTree/actions.ts @@ -4,9 +4,12 @@ import { sew, withAuth, withOrgMembership } from '@/actions'; import { env } from '@/env.mjs'; import { OrgRole, Repo } from '@sourcebot/db'; import { prisma } from '@/prisma'; -import { notFound } from '@/lib/serviceError'; +import { notFound, unexpectedError } from '@/lib/serviceError'; import { simpleGit } from 'simple-git'; import path from 'path'; +import { createLogger } from '@sourcebot/logger'; + +const logger = createLogger('file-tree'); export type FileTreeItem = { type: string; @@ -40,16 +43,23 @@ export const getTree = async (params: { repoName: string, revisionName: string } const { path: repoPath } = getRepoPath(repo); const git = simpleGit().cwd(repoPath); - const result = await git.raw([ - 'ls-tree', - revisionName, - // recursive - '-r', - // include trees when recursing - '-t', - // format as output as {type},{path} - '--format=%(objecttype),%(path)', - ]); + + let result: string; + try { + result = await git.raw([ + 'ls-tree', + revisionName, + // recursive + '-r', + // include trees when recursing + '-t', + // format as output as {type},{path} + '--format=%(objecttype),%(path)', + ]); + } catch (error) { + logger.error('git ls-tree failed.', { error }); + return unexpectedError('git ls-tree command failed.'); + } const lines = result.split('\n').filter(line => line.trim()); @@ -91,24 +101,44 @@ export const getFolderContents = async (params: { repoName: string, revisionName const { path: repoPath } = getRepoPath(repo); + // @note: we don't allow directory traversal + // or null bytes in the path. + if (path.includes('..') || path.includes('\0')) { + return notFound(); + } + + // Normalize the path by... let normalizedPath = path; + // ... adding a trailing slash if it doesn't have one. + // This is important since ls-tree won't return the contents + // of a directory if it doesn't have a trailing slash. if (!normalizedPath.endsWith('/')) { normalizedPath = `${normalizedPath}/`; } + // ... removing any leading slashes. This is needed since + // the path is relative to the repository's root, so we + // need a relative path. if (normalizedPath.startsWith('/')) { normalizedPath = normalizedPath.slice(1); } const git = simpleGit().cwd(repoPath); - const result = await git.raw([ - 'ls-tree', - revisionName, - // format as output as {type},{path} - '--format=%(objecttype),%(path)', - ...(normalizedPath.length === 0 ? [] : [normalizedPath]), - ]); + + let result: string; + try { + result = await git.raw([ + 'ls-tree', + revisionName, + // format as output as {type},{path} + '--format=%(objecttype),%(path)', + ...(normalizedPath.length === 0 ? [] : [normalizedPath]), + ]); + } catch (error) { + logger.error('git ls-tree failed.', { error }); + return unexpectedError('git ls-tree command failed.'); + } const lines = result.split('\n').filter(line => line.trim()); diff --git a/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx b/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx index 8dc856a9c..3bbe9c836 100644 --- a/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx +++ b/packages/web/src/features/fileTree/components/fileTreeItemComponent.tsx @@ -92,13 +92,11 @@ export const FileTreeItemComponent = ({ className="flex flex-row gap-1 cursor-pointer w-4 h-4 flex-shrink-0" > {isCollapseChevronVisible && ( - <> - {isCollapsed ? ( - - ) : ( - - )} - + isCollapsed ? ( + + ) : ( + + ) )}
diff --git a/packages/web/src/features/fileTree/components/fileTreePanel.tsx b/packages/web/src/features/fileTree/components/fileTreePanel.tsx index dd16b3e02..13ea71a38 100644 --- a/packages/web/src/features/fileTree/components/fileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/fileTreePanel.tsx @@ -53,7 +53,7 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => { }, domain) ), }); - + useHotkeys("mod+b", () => { if (isFileTreePanelCollapsed) { fileTreePanelRef.current?.expand(); @@ -182,22 +182,22 @@ const FileTreePanelSkeleton = () => {
- +
- +
- +
- +
@@ -212,7 +212,7 @@ const FileTreePanelSkeleton = () => {
- +
@@ -232,7 +232,7 @@ const FileTreePanelSkeleton = () => {
- +
@@ -242,12 +242,12 @@ const FileTreePanelSkeleton = () => {
- +
- +
@@ -257,7 +257,7 @@ const FileTreePanelSkeleton = () => {
- +
@@ -272,7 +272,7 @@ const FileTreePanelSkeleton = () => {
- +
@@ -282,7 +282,7 @@ const FileTreePanelSkeleton = () => {
- +
@@ -297,7 +297,7 @@ const FileTreePanelSkeleton = () => {
- +
diff --git a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx index a6c73cd30..d0c1deb1c 100644 --- a/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx +++ b/packages/web/src/features/fileTree/components/pureFileTreePanel.tsx @@ -2,11 +2,10 @@ import { FileTreeNode as RawFileTreeNode } from "../actions"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import { useCallback, useMemo, useState, useEffect, useRef } from "react"; +import React, { useCallback, useMemo, useState, useEffect, useRef } from "react"; import { FileTreeItemComponent } from "./fileTreeItemComponent"; import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams"; -import { useDomain } from "@/hooks/useDomain"; import { usePrefetchFileSource } from "@/hooks/usePrefetchFileSource"; @@ -46,7 +45,6 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) const { navigateToPath } = useBrowseNavigation(); const { repoName, revisionName } = useBrowseParams(); const { prefetchFileSource } = usePrefetchFileSource(); - const domain = useDomain(); // @note: When `_tree` changes, it indicates that a new tree has been loaded. // In that case, we need to rebuild the collapsable tree. @@ -108,7 +106,7 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps) <> {nodes.children.map((node) => { return ( - <> + {node.children.length > 0 && !node.isCollapsed && renderTree(node, depth + 1)} - + ); })}