mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
82
web/components/file-viewer/FileBreadcrumb.tsx
Normal file
82
web/components/file-viewer/FileBreadcrumb.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Check, ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileBreadcrumbProps {
|
||||
path: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileBreadcrumb({ path, className }: FileBreadcrumbProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const isAbsolute = path.startsWith("/");
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(path);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const openInVSCode = () => {
|
||||
window.open(`vscode://file${isAbsolute ? path : `/${path}`}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1.5 border-b border-surface-800 bg-surface-900/80",
|
||||
"text-xs text-surface-400 min-w-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Segments */}
|
||||
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
|
||||
{isAbsolute && (
|
||||
<span className="text-surface-600 flex-shrink-0">/</span>
|
||||
)}
|
||||
{segments.map((seg, i) => (
|
||||
<span key={i} className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{i > 0 && <span className="text-surface-700 mx-0.5">/</span>}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate max-w-[120px]",
|
||||
i === segments.length - 1
|
||||
? "text-surface-200 font-medium"
|
||||
: "text-surface-500 hover:text-surface-300 cursor-pointer transition-colors"
|
||||
)}
|
||||
title={seg}
|
||||
>
|
||||
{seg}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-surface-800 transition-colors"
|
||||
title="Copy path"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={openInVSCode}
|
||||
className="p-1 rounded hover:bg-surface-800 transition-colors"
|
||||
title="Open in VS Code"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
web/components/file-viewer/FileInfoBar.tsx
Normal file
98
web/components/file-viewer/FileInfoBar.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Edit3, GitCompare } from "lucide-react";
|
||||
import { useFileViewerStore, type FileTab, type FileViewMode } from "@/lib/fileViewerStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileInfoBarProps {
|
||||
tab: FileTab;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
typescript: "TypeScript",
|
||||
tsx: "TSX",
|
||||
javascript: "JavaScript",
|
||||
jsx: "JSX",
|
||||
python: "Python",
|
||||
rust: "Rust",
|
||||
go: "Go",
|
||||
css: "CSS",
|
||||
scss: "SCSS",
|
||||
html: "HTML",
|
||||
json: "JSON",
|
||||
markdown: "Markdown",
|
||||
bash: "Bash",
|
||||
yaml: "YAML",
|
||||
toml: "TOML",
|
||||
sql: "SQL",
|
||||
graphql: "GraphQL",
|
||||
ruby: "Ruby",
|
||||
java: "Java",
|
||||
c: "C",
|
||||
cpp: "C++",
|
||||
csharp: "C#",
|
||||
php: "PHP",
|
||||
swift: "Swift",
|
||||
kotlin: "Kotlin",
|
||||
dockerfile: "Dockerfile",
|
||||
makefile: "Makefile",
|
||||
text: "Plain Text",
|
||||
};
|
||||
|
||||
const VIEW_MODES: { mode: FileViewMode; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ mode: "view", label: "View", icon: Eye },
|
||||
{ mode: "edit", label: "Edit", icon: Edit3 },
|
||||
{ mode: "diff", label: "Diff", icon: GitCompare },
|
||||
];
|
||||
|
||||
export function FileInfoBar({ tab }: FileInfoBarProps) {
|
||||
const { setMode } = useFileViewerStore();
|
||||
|
||||
const lineCount = tab.content.split("\n").length;
|
||||
const byteSize = new TextEncoder().encode(tab.content).length;
|
||||
const langLabel = LANGUAGE_LABELS[tab.language] ?? tab.language;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1 border-t border-surface-800 bg-surface-950 text-xs text-surface-500">
|
||||
{/* Left: file stats */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-surface-400">{langLabel}</span>
|
||||
<span>UTF-8</span>
|
||||
<span>{lineCount.toLocaleString()} lines</span>
|
||||
<span>{formatBytes(byteSize)}</span>
|
||||
{tab.isDirty && (
|
||||
<span className="text-yellow-500">● Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: mode switcher */}
|
||||
{!tab.isImage && (
|
||||
<div className="flex items-center gap-0.5 bg-surface-900 rounded px-1 py-0.5">
|
||||
{VIEW_MODES.map(({ mode, label, icon: Icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setMode(tab.id, mode)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors",
|
||||
tab.mode === mode
|
||||
? "bg-surface-700 text-surface-100"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
disabled={mode === "diff" && !tab.diff}
|
||||
title={mode === "diff" && !tab.diff ? "No diff available" : label}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
web/components/file-viewer/ImageViewer.tsx
Normal file
107
web/components/file-viewer/ImageViewer.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { ZoomIn, ZoomOut, Maximize2, Image as ImageIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageViewerProps {
|
||||
src: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function ImageViewer({ src, path }: ImageViewerProps) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [fitMode, setFitMode] = useState<"fit" | "actual">("fit");
|
||||
const [error, setError] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleZoomIn = () => setZoom((z) => Math.min(z * 1.25, 8));
|
||||
const handleZoomOut = () => setZoom((z) => Math.max(z / 1.25, 0.1));
|
||||
const handleFitToggle = () => {
|
||||
setFitMode((m) => (m === "fit" ? "actual" : "fit"));
|
||||
setZoom(1);
|
||||
};
|
||||
|
||||
const isSvg = path.endsWith(".svg");
|
||||
const hasTransparency = path.endsWith(".png") || path.endsWith(".gif") ||
|
||||
path.endsWith(".webp") || path.endsWith(".svg");
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-surface-500">
|
||||
<ImageIcon className="w-10 h-10" />
|
||||
<p className="text-sm">Failed to load image</p>
|
||||
<p className="text-xs text-surface-600">{path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-surface-800 bg-surface-900/50">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs text-surface-400 w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-surface-800 mx-1" />
|
||||
<button
|
||||
onClick={handleFitToggle}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors",
|
||||
fitMode === "fit"
|
||||
? "text-brand-400 bg-brand-900/30"
|
||||
: "text-surface-500 hover:text-surface-200 hover:bg-surface-800"
|
||||
)}
|
||||
title={fitMode === "fit" ? "Switch to actual size" : "Switch to fit width"}
|
||||
>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
{fitMode === "fit" ? "Fit" : "Actual"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Image container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-auto flex items-center justify-center p-4"
|
||||
style={{
|
||||
backgroundImage: hasTransparency
|
||||
? "linear-gradient(45deg, #3f3f46 25%, transparent 25%), linear-gradient(-45deg, #3f3f46 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #3f3f46 75%), linear-gradient(-45deg, transparent 75%, #3f3f46 75%)"
|
||||
: undefined,
|
||||
backgroundSize: hasTransparency ? "16px 16px" : undefined,
|
||||
backgroundPosition: hasTransparency ? "0 0, 0 8px, 8px -8px, -8px 0px" : undefined,
|
||||
backgroundColor: hasTransparency ? "#27272a" : "#1a1a1e",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={path.split("/").pop()}
|
||||
onError={() => setError(true)}
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
maxWidth: fitMode === "fit" ? "100%" : "none",
|
||||
maxHeight: fitMode === "fit" ? "100%" : "none",
|
||||
imageRendering: zoom > 2 ? "pixelated" : "auto",
|
||||
}}
|
||||
className="transition-transform duration-150 shadow-lg"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
web/components/file-viewer/SearchBar.tsx
Normal file
250
web/components/file-viewer/SearchBar.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, ChevronUp, ChevronDown, Regex, CaseSensitive } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SearchBarProps {
|
||||
content: string;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SearchBar({ content, containerRef, onClose }: SearchBarProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isRegex, setIsRegex] = useState(false);
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [currentMatch, setCurrentMatch] = useState(0);
|
||||
const [totalMatches, setTotalMatches] = useState(0);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Compute matches
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
setTotalMatches(0);
|
||||
setCurrentMatch(0);
|
||||
clearHighlights();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const flags = caseSensitive ? "g" : "gi";
|
||||
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
|
||||
const matches = Array.from(content.matchAll(pattern));
|
||||
setTotalMatches(matches.length);
|
||||
setCurrentMatch(matches.length > 0 ? 1 : 0);
|
||||
setHasError(false);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
setTotalMatches(0);
|
||||
setCurrentMatch(0);
|
||||
}
|
||||
}, [query, isRegex, caseSensitive, content]);
|
||||
|
||||
// Apply DOM highlights
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
clearHighlights();
|
||||
|
||||
if (!query || hasError || totalMatches === 0) return;
|
||||
|
||||
try {
|
||||
const flags = caseSensitive ? "g" : "gi";
|
||||
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
containerRef.current,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
// Skip nodes inside already-marked elements
|
||||
if ((node.parentElement as HTMLElement)?.tagName === "MARK") {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
let matchIdx = 0;
|
||||
// Process in reverse order to avoid position shifting
|
||||
const replacements: { node: Text; ranges: { start: number; end: number; idx: number }[] }[] = [];
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
const text = textNode.textContent ?? "";
|
||||
pattern.lastIndex = 0;
|
||||
const nodeRanges: { start: number; end: number; idx: number }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pattern.exec(text)) !== null) {
|
||||
nodeRanges.push({ start: m.index, end: m.index + m[0].length, idx: matchIdx++ });
|
||||
if (m[0].length === 0) break; // prevent infinite loop on zero-width matches
|
||||
}
|
||||
if (nodeRanges.length > 0) {
|
||||
replacements.push({ node: textNode, ranges: nodeRanges });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply replacements in document order but process ranges in reverse
|
||||
for (const { node: textNode, ranges } of replacements) {
|
||||
const text = textNode.textContent ?? "";
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastEnd = 0;
|
||||
|
||||
for (const { start, end, idx } of ranges) {
|
||||
if (start > lastEnd) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastEnd, start)));
|
||||
}
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = cn(
|
||||
"search-highlight",
|
||||
idx === currentMatch - 1 ? "search-highlight-current" : ""
|
||||
);
|
||||
mark.textContent = text.slice(start, end);
|
||||
fragment.appendChild(mark);
|
||||
lastEnd = end;
|
||||
}
|
||||
if (lastEnd < text.length) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastEnd)));
|
||||
}
|
||||
textNode.parentNode?.replaceChild(fragment, textNode);
|
||||
}
|
||||
|
||||
// Scroll current match into view
|
||||
const currentEl = containerRef.current?.querySelector(".search-highlight-current");
|
||||
currentEl?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
} catch {
|
||||
// Ignore DOM errors
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (containerRef.current) clearHighlights();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, isRegex, caseSensitive, currentMatch, totalMatches]);
|
||||
|
||||
function clearHighlights() {
|
||||
if (!containerRef.current) return;
|
||||
const marks = containerRef.current.querySelectorAll("mark.search-highlight");
|
||||
marks.forEach((mark) => {
|
||||
mark.replaceWith(mark.textContent ?? "");
|
||||
});
|
||||
// Normalize text nodes
|
||||
containerRef.current.normalize();
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
setCurrentMatch((c) => (c >= totalMatches ? 1 : c + 1));
|
||||
};
|
||||
|
||||
const goPrev = () => {
|
||||
setCurrentMatch((c) => (c <= 1 ? totalMatches : c - 1));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.shiftKey ? goPrev() : goNext();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
clearHighlights();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-surface-800 bg-surface-900/90 backdrop-blur-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 flex-1 bg-surface-800 rounded px-2 py-1",
|
||||
hasError && "ring-1 ring-red-500/50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-xs text-surface-100 placeholder-surface-500 outline-none min-w-0"
|
||||
/>
|
||||
|
||||
{/* Match count */}
|
||||
{query && (
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0",
|
||||
hasError ? "text-red-400" : totalMatches === 0 ? "text-red-400" : "text-surface-400"
|
||||
)}>
|
||||
{hasError ? "Invalid regex" : totalMatches === 0 ? "No results" : `${currentMatch}/${totalMatches}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Toggles */}
|
||||
<button
|
||||
onClick={() => setCaseSensitive((v) => !v)}
|
||||
className={cn(
|
||||
"p-0.5 rounded transition-colors flex-shrink-0",
|
||||
caseSensitive
|
||||
? "text-brand-400 bg-brand-900/40"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
title="Case sensitive"
|
||||
>
|
||||
<CaseSensitive className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsRegex((v) => !v)}
|
||||
className={cn(
|
||||
"p-0.5 rounded transition-colors flex-shrink-0",
|
||||
isRegex
|
||||
? "text-brand-400 bg-brand-900/40"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
title="Use regular expression"
|
||||
>
|
||||
<Regex className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<button
|
||||
onClick={goPrev}
|
||||
disabled={totalMatches === 0}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
|
||||
title="Previous match (Shift+Enter)"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={totalMatches === 0}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
|
||||
title="Next match (Enter)"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { clearHighlights(); onClose(); }}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Close (Escape)"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user