Skip to content

Commit aa58cd3

Browse files
fix(web): resolve path type for the commits view and tighten path handling
The commits view URL pattern (/-/commits/<path>) doesn't carry whether the path is a file or a folder, so PathHeader was rendering folder paths with the file-icon last-segment treatment. Add a small getPathType helper backed by `git cat-file -t <ref>:<path>` and use it to pick the correct PathHeader behaviour. CommitRow and HistoryRow now gate the "view code at this commit" action on the same blob check so the file-only link doesn't appear on folder rows. Also fix a related path-handling bug: PathHeader's repo-name link called getBrowsePath with `path: '/'`, which encoded as `%2F` and parsed back as `path: '/'` — that leaked through as `git log -- /`, which git correctly rejects with "outside repository". Strip leading slashes both when generating and when parsing browse URLs so any path that comes through as a literal `/` resolves to the repo root. When clicking "View full history" the bottom panel now collapses on the same click, so the new full-view page renders without the panel still hovering on top of it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f40c691 commit aa58cd3

8 files changed

Lines changed: 126 additions & 15 deletions

File tree

packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { formatDistanceToNow } from "date-fns";
55
import { Code, FileCode } from "lucide-react";
66
import { CopyIconButton } from "@/app/(app)/components/copyIconButton";
77
import { useToast } from "@/components/hooks/use-toast";
8-
import type { Commit } from "@/features/git";
8+
import type { Commit, GitObjectPathType } from "@/features/git";
99
import { getBrowsePath } from "../../hooks/utils";
1010
import { formatAuthorsText, getCommitAuthors } from "../../components/commitAuthors";
1111
import {
@@ -19,16 +19,17 @@ interface CommitRowProps {
1919
commit: Commit;
2020
repoName: string;
2121
path: string;
22+
pathType: GitObjectPathType;
2223
}
2324

24-
export const CommitRow = ({ commit, repoName, path }: CommitRowProps) => {
25+
export const CommitRow = ({ commit, repoName, path, pathType }: CommitRowProps) => {
2526
const [isBodyExpanded, setIsBodyExpanded] = useState(false);
2627
const { toast } = useToast();
2728

2829
const shortSha = commit.hash.slice(0, 7);
2930
const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true });
3031
const hasBody = commit.body.trim().length > 0;
31-
const hasFilePath = path !== '' && path !== '/';
32+
const isBlobPath = pathType === 'blob';
3233

3334
const authors = useMemo(
3435
() => getCommitAuthors(commit),
@@ -82,7 +83,7 @@ export const CommitRow = ({ commit, repoName, path }: CommitRowProps) => {
8283
{shortSha}
8384
</span>
8485
<CopyIconButton onCopy={onCopySha} />
85-
{hasFilePath && (
86+
{isBlobPath && (
8687
<CommitActionLink
8788
href={viewFileAtCommitHref}
8889
label="View code at this commit"

packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { GitCommitHorizontal } from "lucide-react";
33
import { getRepoInfoByName } from "@/actions";
44
import { PathHeader } from "@/app/(app)/components/pathHeader";
55
import { Separator } from "@/components/ui/separator";
6-
import { listCommitAuthors, listCommits } from "@/features/git";
6+
import { getPathType, listCommitAuthors, listCommits } from "@/features/git";
77
import { isServiceError } from "@/lib/utils";
88
import { AuthorFilter } from "./authorFilter";
99
import { dedupeCommitAuthorsByEmail, escapeGitBreLiteral } from "../../components/commitAuthors";
@@ -34,7 +34,7 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author,
3434
const sinceForGit = since ? `${since}T00:00:00` : undefined;
3535
const untilForGit = until ? `${until}T23:59:59` : undefined;
3636

37-
const [commitsResponse, repoInfoResponse, authorsResponse] = await Promise.all([
37+
const [commitsResponse, repoInfoResponse, authorsResponse, pathTypeResponse] = await Promise.all([
3838
listCommits({
3939
repo: repoName,
4040
path: path || undefined,
@@ -53,6 +53,11 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author,
5353
maxCount: AUTHORS_PER_PAGE,
5454
skip: 0,
5555
}),
56+
getPathType({
57+
repo: repoName,
58+
ref: revisionName,
59+
path,
60+
}),
5661
]);
5762

5863
if (isServiceError(commitsResponse)) {
@@ -64,6 +69,8 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author,
6469
if (isServiceError(authorsResponse)) {
6570
return <div className="p-4 text-sm">Error loading commit authors: {authorsResponse.message}</div>;
6671
}
72+
// Fall back to 'blob' if the lookup fails so PathHeader still renders.
73+
const headerPathType = isServiceError(pathTypeResponse) ? 'blob' : pathTypeResponse;
6774

6875
const authors = dedupeCommitAuthorsByEmail(authorsResponse.authors);
6976
const { commits, totalCount } = commitsResponse;
@@ -89,13 +96,15 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author,
8996
<span className="text-sm text-muted-foreground flex-shrink-0">History for</span>
9097
<PathHeader
9198
path={path}
99+
pathType={headerPathType}
92100
repo={{
93101
name: repoName,
94102
codeHostType: repoInfoResponse.codeHostType,
95103
displayName: repoInfoResponse.displayName,
96104
externalWebUrl: repoInfoResponse.externalWebUrl,
97105
}}
98106
revisionName={revisionName}
107+
isFileIconVisible={headerPathType === 'blob'}
99108
/>
100109
</div>
101110
<div className="flex flex-row items-center gap-2 flex-shrink-0">
@@ -117,6 +126,7 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author,
117126
commit={commit}
118127
repoName={repoName}
119128
path={path}
129+
pathType={headerPathType}
120130
/>
121131
))}
122132
</div>

packages/web/src/app/(app)/browse/components/bottomPanel.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ export const BottomPanel = ({ order }: BottomPanelProps) => {
130130
<div className="flex flex-row items-center gap-1 self-center">
131131
{activeBottomPanelTab === "history" && (
132132
<Button asChild variant="ghost" size="sm">
133-
<Link href={fullHistoryHref}>
133+
<Link
134+
href={fullHistoryHref}
135+
onClick={() => updateBrowseState({ isBottomPanelCollapsed: true })}
136+
>
134137
<ExternalLink className="w-4 h-4" />
135138
View full history
136139
</Link>

packages/web/src/app/(app)/browse/components/historyPanel.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const PER_PAGE = 25;
1414
type CommitsPage = ListCommitsResponse & { page: number };
1515

1616
export const HistoryPanel = () => {
17-
const { repoName, revisionName, path } = useBrowseParams();
17+
const { repoName, revisionName, path, pathType } = useBrowseParams();
1818

1919
const {
2020
data,
@@ -94,6 +94,7 @@ export const HistoryPanel = () => {
9494
commit={commit}
9595
repoName={repoName}
9696
path={path}
97+
pathType={pathType}
9798
/>
9899
))}
99100
{hasNextPage && (

packages/web/src/app/(app)/browse/components/historyRow.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,21 @@ import { useMemo } from "react";
44
import { formatDistanceToNow } from "date-fns";
55
import { Code, FileCode } from "lucide-react";
66
import type { Commit } from "@/features/git";
7-
import { getBrowsePath } from "../hooks/utils";
7+
import { BrowsePathType, getBrowsePath } from "../hooks/utils";
88
import { formatAuthorsText, getCommitAuthors } from "./commitAuthors";
99
import { AuthorsAvatarGroup, CommitActionLink } from "./commitParts";
1010

1111
interface HistoryRowProps {
1212
commit: Commit;
1313
repoName: string;
1414
path: string;
15+
pathType: BrowsePathType;
1516
}
1617

17-
export const HistoryRow = ({ commit, repoName, path }: HistoryRowProps) => {
18+
export const HistoryRow = ({ commit, repoName, path, pathType }: HistoryRowProps) => {
1819
const shortSha = commit.hash.slice(0, 7);
1920
const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true });
20-
const hasFilePath = path !== '' && path !== '/';
21+
const isBlobPath = pathType === 'blob';
2122

2223
const authors = useMemo(() => getCommitAuthors(commit), [commit]);
2324

@@ -60,7 +61,7 @@ export const HistoryRow = ({ commit, repoName, path }: HistoryRowProps) => {
6061
{relativeDate}
6162
</span>
6263
<div className="flex flex-row items-center gap-1 flex-shrink-0">
63-
{hasFilePath && (
64+
{isBlobPath && (
6465
<CommitActionLink
6566
href={viewCodeHref}
6667
label="View code at this commit"

packages/web/src/app/(app)/browse/hooks/utils.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,28 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => {
6161
}
6262
})();
6363

64-
if (pathType === 'blob' && path === '') {
64+
// Normalize parsed paths the same way URL generation does, so URLs that
65+
// happen to contain a leading slash (e.g. legacy bookmarks with `%2F`)
66+
// don't leak `/foo` into git log args.
67+
const normalizedPath = path.replace(/^\/+/, '');
68+
69+
if (pathType === 'blob' && normalizedPath === '') {
6570
throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a path for blob type`);
6671
}
6772

6873
return {
6974
repoName,
7075
revisionName,
71-
path,
76+
path: normalizedPath,
7277
pathType,
7378
}
7479
};
7580

81+
// Repo-relative paths shouldn't have leading slashes — `git log -- /foo` (or
82+
// just `--`) treats them as absolute filesystem paths. Repo root and `/`
83+
// both map to the empty path.
84+
const normalizeRepoPath = (path: string): string => path.replace(/^\/+/, '');
85+
7686
export const getBrowsePath = ({
7787
repoName, revisionName, path, pathType, highlightRange, setBrowseState,
7888
}: GetBrowsePathProps) => {
@@ -92,7 +102,7 @@ export const getBrowsePath = ({
92102
params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState));
93103
}
94104

95-
const encodedPath = encodeURIComponent(path);
105+
const encodedPath = encodeURIComponent(normalizeRepoPath(path));
96106
const browsePath = `/browse/${repoName}${revisionName ? `@${revisionName}` : ''}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`;
97107
return browsePath;
98108
};
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { sew } from "@/middleware/sew";
2+
import { invalidGitRef, notFound, ServiceError, unexpectedError } from '@/lib/serviceError';
3+
import { withOptionalAuth } from '@/middleware/withAuth';
4+
import { getRepoPath } from '@sourcebot/shared';
5+
import { simpleGit } from 'simple-git';
6+
import { isGitRefValid } from './utils';
7+
8+
export type GitObjectPathType = 'blob' | 'tree';
9+
10+
type GetPathTypeRequest = {
11+
repo: string;
12+
ref?: string;
13+
path: string;
14+
};
15+
16+
/**
17+
* Resolve whether a given path inside a repo is a file (`blob`) or a
18+
* directory (`tree`) at the supplied ref. Empty paths always resolve to
19+
* `tree` (the repo root).
20+
*
21+
* Backed by `git cat-file -t <ref>:<path>`, which is a constant-time
22+
* object lookup — no walking, just an index read.
23+
*/
24+
export const getPathType = async ({
25+
repo: repoName,
26+
ref = 'HEAD',
27+
path,
28+
}: GetPathTypeRequest): Promise<GitObjectPathType | ServiceError> => sew(() =>
29+
withOptionalAuth(async ({ org, prisma }) => {
30+
if (path === '' || path === '/') {
31+
return 'tree';
32+
}
33+
34+
const repo = await prisma.repo.findFirst({
35+
where: {
36+
name: repoName,
37+
orgId: org.id,
38+
},
39+
});
40+
41+
if (!repo) {
42+
return notFound(`Repository "${repoName}" not found.`);
43+
}
44+
45+
if (!isGitRefValid(ref)) {
46+
return invalidGitRef(ref);
47+
}
48+
49+
const { path: repoPath } = getRepoPath(repo);
50+
const git = simpleGit().cwd(repoPath);
51+
52+
try {
53+
const output = await git.raw(['cat-file', '-t', `${ref}:${path}`]);
54+
const type = output.trim();
55+
if (type === 'blob' || type === 'tree') {
56+
return type;
57+
}
58+
return notFound(`Path "${path}" at ref "${ref}" is not a file or directory.`);
59+
} catch (error: unknown) {
60+
const errorMessage = error instanceof Error ? error.message : String(error);
61+
62+
if (errorMessage.includes('not a git repository')) {
63+
return unexpectedError(
64+
`Invalid git repository at ${repoPath}. `
65+
+ `The directory exists but is not a valid git repository.`,
66+
);
67+
}
68+
69+
// `git cat-file` returns "Not a valid object name <ref>:<path>" when
70+
// the path doesn't exist at that ref. Treat as not-found.
71+
if (errorMessage.includes('Not a valid object name')) {
72+
return notFound(`Path "${path}" not found at ref "${ref}".`);
73+
}
74+
75+
if (error instanceof Error) {
76+
throw new Error(
77+
`Failed to resolve path type for ${repoName}:${path}: ${error.message}`,
78+
);
79+
}
80+
throw new Error(
81+
`Failed to resolve path type for ${repoName}:${path}: ${errorMessage}`,
82+
);
83+
}
84+
}));

packages/web/src/features/git/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from './getTreeApi';
66
export * from './getFileSourceApi';
77
export * from './listCommitsApi';
88
export * from './listCommitAuthorsApi';
9+
export * from './getPathTypeApi';
910
export * from './types';

0 commit comments

Comments
 (0)