87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useCallback } from "react";
|
|
import { cn, toRelativePath } from "@/lib/utils";
|
|
|
|
type FilePathButtonProps = {
|
|
filePath: string;
|
|
worktreePath?: string;
|
|
onOpenFile?: (path: string) => void;
|
|
variant?: "badge" | "list-item" | "link";
|
|
className?: string;
|
|
};
|
|
|
|
/**
|
|
* Check if a file path looks like a valid, openable file (not a directory or invalid path).
|
|
* Returns false for:
|
|
* - Empty paths or just whitespace
|
|
* - Single dot "." (current directory)
|
|
* - Paths ending with "/" (directories)
|
|
* - Paths without a file extension in the last segment (likely directories)
|
|
*/
|
|
function isOpenableFilePath(path: string): boolean {
|
|
if (!path || !path.trim()) return false;
|
|
const trimmed = path.trim();
|
|
if (trimmed === ".") return false;
|
|
if (trimmed.endsWith("/")) return false;
|
|
// Get the last segment of the path
|
|
const lastSegment = trimmed.split("/").pop() || "";
|
|
// If last segment has no extension and doesn't start with a dot (hidden file), it's likely a directory
|
|
if (!lastSegment.includes(".")) return false;
|
|
return true;
|
|
}
|
|
|
|
export const FilePathButton = memo(function FilePathButton({
|
|
filePath,
|
|
worktreePath,
|
|
onOpenFile,
|
|
variant = "badge",
|
|
className = "",
|
|
}: FilePathButtonProps) {
|
|
const relativePath = toRelativePath(filePath, worktreePath);
|
|
const canOpen = isOpenableFilePath(filePath);
|
|
|
|
const handleClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onOpenFile?.(filePath);
|
|
},
|
|
[onOpenFile, filePath],
|
|
);
|
|
|
|
const baseStyles = "font-mono truncate cursor-pointer transition-colors block w-full text-left";
|
|
const variantStyles = {
|
|
badge:
|
|
"text-xs px-1.5 py-0.5 rounded bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-400 hover:underline",
|
|
"list-item":
|
|
"flex items-center px-2 py-1 rounded hover:bg-indigo-500/20 text-indigo-400 hover:underline",
|
|
link: "text-xs text-muted-foreground hover:text-foreground underline decoration-muted-foreground/40 hover:decoration-foreground/60",
|
|
}[variant];
|
|
|
|
if (onOpenFile && canOpen) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={handleClick}
|
|
className={cn(baseStyles, variantStyles, className)}
|
|
title={filePath}
|
|
>
|
|
{relativePath}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const inactiveStyles = {
|
|
badge:
|
|
"text-xs text-muted-foreground/60 truncate font-mono bg-muted/30 px-1.5 py-0.5 rounded block w-full",
|
|
"list-item": "px-2 py-1 font-mono text-muted-foreground truncate block w-full",
|
|
link: "text-xs font-mono text-muted-foreground/60 truncate block w-full",
|
|
}[variant];
|
|
|
|
return (
|
|
<span className={cn(inactiveStyles, className)} title={filePath}>
|
|
{relativePath}
|
|
</span>
|
|
);
|
|
});
|