Skip to content

Commit cfe5a7c

Browse files
wip
1 parent 4889658 commit cfe5a7c

8 files changed

Lines changed: 390 additions & 127 deletions

File tree

packages/web/src/app/api/(client)/client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
import {
1919
GetFilesRequest,
2020
GetFilesResponse,
21+
GetFolderContentsRequest,
22+
GetFolderContentsResponse,
2123
GetTreeRequest,
2224
GetTreeResponse,
2325
} from "@/features/fileTree/types";
@@ -101,4 +103,12 @@ export const getFiles = async (body: GetFilesRequest): Promise<GetFilesResponse
101103
body: JSON.stringify(body),
102104
}).then(response => response.json());
103105
return result as GetFilesResponse | ServiceError;
106+
}
107+
108+
export const getFolderContents = async (body: GetFolderContentsRequest): Promise<GetFolderContentsResponse | ServiceError> => {
109+
const result = await fetch("/api/folder_contents", {
110+
method: "POST",
111+
body: JSON.stringify(body),
112+
}).then(response => response.json());
113+
return result as GetFolderContentsResponse | ServiceError;
104114
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use server';
2+
3+
import { getFolderContents } from "@/features/fileTree/api";
4+
import { getFolderContentsRequestSchema } from "@/features/fileTree/types";
5+
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
6+
import { isServiceError } from "@/lib/utils";
7+
import { NextRequest } from "next/server";
8+
9+
export const POST = async (request: NextRequest) => {
10+
const body = await request.json();
11+
const parsed = await getFolderContentsRequestSchema.safeParseAsync(body);
12+
if (!parsed.success) {
13+
return serviceErrorResponse(schemaValidationError(parsed.error));
14+
}
15+
16+
const response = await getFolderContents(parsed.data);
17+
if (isServiceError(response)) {
18+
return serviceErrorResponse(response);
19+
}
20+
21+
return Response.json(response);
22+
}
23+

packages/web/src/features/fileTree/api.ts

Lines changed: 9 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { Repo } from '@sourcebot/db';
88
import { createLogger } from '@sourcebot/shared';
99
import path from 'path';
1010
import { simpleGit } from 'simple-git';
11-
import { FileTreeItem, FileTreeNode } from './types';
11+
import { FileTreeItem } from './types';
12+
import { buildFileTree, getPathspecs, isPathValid, normalizePath } from './utils';
1213

1314
const logger = createLogger('file-tree');
1415

@@ -41,17 +42,22 @@ export const getTree = async (params: { repoName: string, revisionName: string,
4142

4243
let result: string = '';
4344
try {
44-
result= await git.raw([
45+
46+
const command = [
4547
// Disable quoting of non-ASCII characters in paths
4648
'-c', 'core.quotePath=false',
4749
'ls-tree',
4850
revisionName,
4951
// format as output as {type},{path}
5052
'--format=%(objecttype),%(path)',
53+
// include tree nodes
54+
'-t',
5155
'--',
5256
'.',
5357
...pathSpecs,
54-
])
58+
];
59+
60+
result = await git.raw(command);
5561
} catch (error) {
5662
logger.error('git ls-tree failed.', { error });
5763
return unexpectedError('git ls-tree command failed.');
@@ -184,104 +190,6 @@ export const getFiles = async (params: { repoName: string, revisionName: string
184190

185191
}));
186192

187-
// @note: we don't allow directory traversal
188-
// or null bytes in the path.
189-
const isPathValid = (path: string) => {
190-
return !path.includes('..') && !path.includes('\0');
191-
}
192-
193-
const normalizePath = (path: string): string => {
194-
// Normalize the path by...
195-
let normalizedPath = path;
196-
197-
// ... adding a trailing slash if it doesn't have one.
198-
// This is important since ls-tree won't return the contents
199-
// of a directory if it doesn't have a trailing slash.
200-
if (!normalizedPath.endsWith('/')) {
201-
normalizedPath = `${normalizedPath}/`;
202-
}
203-
204-
// ... removing any leading slashes. This is needed since
205-
// the path is relative to the repository's root, so we
206-
// need a relative path.
207-
if (normalizedPath.startsWith('/')) {
208-
normalizedPath = normalizedPath.slice(1);
209-
}
210-
211-
return normalizedPath;
212-
}
213-
214-
const getPathspecs = (path: string): string[] => {
215-
const normalizedPath = normalizePath(path);
216-
if (normalizedPath.length === 0) {
217-
return [];
218-
}
219-
220-
const parts = normalizedPath.split('/').filter(part => part.length > 0);
221-
const pathspecs: string[] = [];
222-
223-
for (let i = 0; i < parts.length; i++) {
224-
const prefix = parts.slice(0, i + 1).join('/');
225-
pathspecs.push(`${prefix}/`);
226-
}
227-
228-
return pathspecs;
229-
}
230-
231-
const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => {
232-
const root: FileTreeNode = {
233-
name: 'root',
234-
path: '',
235-
type: 'tree',
236-
children: [],
237-
};
238-
239-
for (const item of flatList) {
240-
const parts = item.path.split('/');
241-
let current: FileTreeNode = root;
242-
243-
for (let i = 0; i < parts.length; i++) {
244-
const part = parts[i];
245-
const isLeaf = i === parts.length - 1;
246-
const nodeType = isLeaf ? item.type : 'tree';
247-
let next = current.children.find((child: FileTreeNode) => child.name === part && child.type === nodeType);
248-
249-
if (!next) {
250-
next = {
251-
name: part,
252-
path: item.path,
253-
type: nodeType,
254-
children: [],
255-
};
256-
current.children.push(next);
257-
}
258-
current = next;
259-
}
260-
}
261-
262-
const sortTree = (node: FileTreeNode): FileTreeNode => {
263-
if (node.type === 'blob') {
264-
return node;
265-
}
266-
267-
const sortedChildren = node.children
268-
.map(sortTree)
269-
.sort((a: FileTreeNode, b: FileTreeNode) => {
270-
if (a.type !== b.type) {
271-
return a.type === 'tree' ? -1 : 1;
272-
}
273-
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
274-
});
275-
276-
return {
277-
...node,
278-
children: sortedChildren,
279-
};
280-
};
281-
282-
return sortTree(root);
283-
}
284-
285193
// @todo: this is duplicated from the `getRepoPath` function in the
286194
// backend's `utils.ts` file. Eventually we should move this to a shared
287195
// package.

packages/web/src/features/fileTree/components/fileTreePanel.tsx

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
44
import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState";
5-
import { getTree } from "@/app/api/(client)/client";
5+
import { getFolderContents, getTree } from "@/app/api/(client)/client";
66
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
77
import { Button } from "@/components/ui/button";
88
import { ResizablePanel } from "@/components/ui/resizable";
@@ -12,14 +12,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
1212
import { unwrapServiceError } from "@/lib/utils";
1313
import { useQuery } from "@tanstack/react-query";
1414
import { SearchIcon } from "lucide-react";
15-
import { useRef } from "react";
15+
import { useCallback, useEffect, useRef, useState } from "react";
1616
import { useHotkeys } from "react-hotkeys-hook";
1717
import {
1818
GoSidebarExpand as CollapseIcon,
1919
GoSidebarCollapse as ExpandIcon
2020
} from "react-icons/go";
2121
import { ImperativePanelHandle } from "react-resizable-panels";
2222
import { PureFileTreePanel } from "./pureFileTreePanel";
23+
import { FileTreeNode } from "../types";
2324

2425
interface FileTreePanelProps {
2526
order: number;
@@ -29,7 +30,6 @@ const FILE_TREE_PANEL_DEFAULT_SIZE = 20;
2930
const FILE_TREE_PANEL_MIN_SIZE = 10;
3031
const FILE_TREE_PANEL_MAX_SIZE = 30;
3132

32-
3333
export const FileTreePanel = ({ order }: FileTreePanelProps) => {
3434
const {
3535
state: {
@@ -40,8 +40,20 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
4040

4141
const { repoName, revisionName, path } = useBrowseParams();
4242

43+
const [tree, setTree] = useState<FileTreeNode | null>(null);
44+
4345
const fileTreePanelRef = useRef<ImperativePanelHandle>(null);
44-
const { data, isPending, isError } = useQuery({
46+
const loadFolderContents = useCallback(async (folderPath: string) => {
47+
return unwrapServiceError(
48+
getFolderContents({
49+
repoName,
50+
revisionName: revisionName ?? 'HEAD',
51+
path: folderPath
52+
})
53+
);
54+
}, [repoName, revisionName]);
55+
56+
const { data, isError } = useQuery({
4557
queryKey: ['tree', repoName, revisionName, path],
4658
queryFn: () => unwrapServiceError(
4759
getTree({
@@ -64,6 +76,13 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
6476
description: "Toggle file tree panel",
6577
});
6678

79+
useEffect(() => {
80+
if (!data) {
81+
return;
82+
}
83+
setTree(data.tree);
84+
}, [data]);
85+
6786
return (
6887
<>
6988
<ResizablePanel
@@ -122,7 +141,7 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
122141
</Tooltip>
123142
</div>
124143
<Separator orientation="horizontal" className="w-full mb-2" />
125-
{isPending ? (
144+
{!tree ? (
126145
<FileTreePanelSkeleton />
127146
) :
128147
isError ? (
@@ -131,8 +150,9 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
131150
</div>
132151
) : (
133152
<PureFileTreePanel
134-
tree={data.tree}
153+
tree={tree}
135154
path={path}
155+
onLoadChildren={loadFolderContents}
136156
/>
137157
)}
138158
</div>

0 commit comments

Comments
 (0)