Adds two new features for running project containers: 1. Bash Shell Tab: A "Shell" button on running projects opens a plain bash -l session instead of Claude Code, useful for direct container inspection, package installation, and debugging. Tab labels show "(bash)" suffix to distinguish from Claude sessions. 2. File Manager: A "Files" button opens a modal file browser for navigating container directories, downloading files to the host, and uploading files from the host. Supports breadcrumb navigation and works with any path including those outside mounted projects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
198 lines
7.0 KiB
TypeScript
198 lines
7.0 KiB
TypeScript
import { useEffect, useRef, useCallback } from "react";
|
||
import { useFileManager } from "../../hooks/useFileManager";
|
||
|
||
interface Props {
|
||
projectId: string;
|
||
projectName: string;
|
||
onClose: () => void;
|
||
}
|
||
|
||
function formatSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||
}
|
||
|
||
export default function FileManagerModal({ projectId, projectName, onClose }: Props) {
|
||
const {
|
||
currentPath,
|
||
entries,
|
||
loading,
|
||
error,
|
||
navigate,
|
||
goUp,
|
||
refresh,
|
||
downloadFile,
|
||
uploadFile,
|
||
} = useFileManager(projectId);
|
||
const overlayRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Load initial directory
|
||
useEffect(() => {
|
||
navigate("/workspace");
|
||
}, [navigate]);
|
||
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") onClose();
|
||
};
|
||
document.addEventListener("keydown", handleKeyDown);
|
||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||
}, [onClose]);
|
||
|
||
const handleOverlayClick = useCallback(
|
||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||
if (e.target === overlayRef.current) onClose();
|
||
},
|
||
[onClose],
|
||
);
|
||
|
||
// Build breadcrumbs from current path
|
||
const breadcrumbs = currentPath === "/"
|
||
? [{ label: "/", path: "/" }]
|
||
: currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
||
if (i === 0) {
|
||
acc.push({ label: "/", path: "/" });
|
||
} else if (part) {
|
||
const parentPath = acc[acc.length - 1].path;
|
||
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
||
acc.push({ label: part, path: fullPath });
|
||
}
|
||
return acc;
|
||
}, []);
|
||
|
||
return (
|
||
<div
|
||
ref={overlayRef}
|
||
onClick={handleOverlayClick}
|
||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||
>
|
||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[36rem] max-h-[80vh] flex flex-col">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)]">
|
||
<h2 className="text-sm font-semibold">Files — {projectName}</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Path bar */}
|
||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||
{breadcrumbs.map((crumb, i) => (
|
||
<span key={crumb.path} className="flex items-center gap-1">
|
||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||
<button
|
||
onClick={() => navigate(crumb.path)}
|
||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap"
|
||
>
|
||
{crumb.label}
|
||
</button>
|
||
</span>
|
||
))}
|
||
<div className="flex-1" />
|
||
<button
|
||
onClick={refresh}
|
||
disabled={loading}
|
||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50 px-1"
|
||
title="Refresh"
|
||
>
|
||
↻
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto min-h-0">
|
||
{error && (
|
||
<div className="px-4 py-2 text-xs text-[var(--error)]">{error}</div>
|
||
)}
|
||
|
||
{loading && entries.length === 0 ? (
|
||
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
||
Loading...
|
||
</div>
|
||
) : (
|
||
<table className="w-full text-xs">
|
||
<tbody>
|
||
{/* Go up entry */}
|
||
{currentPath !== "/" && (
|
||
<tr
|
||
onClick={() => goUp()}
|
||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||
>
|
||
<td className="px-4 py-1.5 text-[var(--text-primary)]">..</td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
</tr>
|
||
)}
|
||
{entries.map((entry) => (
|
||
<tr
|
||
key={entry.name}
|
||
onClick={() => entry.is_directory && navigate(entry.path)}
|
||
className={`${
|
||
entry.is_directory ? "cursor-pointer" : ""
|
||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
||
>
|
||
<td className="px-4 py-1.5">
|
||
<span className={entry.is_directory ? "text-[var(--accent)]" : "text-[var(--text-primary)]"}>
|
||
{entry.is_directory ? "📁 " : ""}{entry.name}
|
||
</span>
|
||
</td>
|
||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap">
|
||
{!entry.is_directory && formatSize(entry.size)}
|
||
</td>
|
||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||
{entry.modified}
|
||
</td>
|
||
<td className="px-2 py-1.5 text-right">
|
||
{!entry.is_directory && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
downloadFile(entry);
|
||
}}
|
||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors px-1"
|
||
title="Download"
|
||
>
|
||
↓
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{entries.length === 0 && !loading && (
|
||
<tr>
|
||
<td colSpan={4} className="px-4 py-8 text-center text-[var(--text-secondary)]">
|
||
Empty directory
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)]">
|
||
<button
|
||
onClick={uploadFile}
|
||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||
>
|
||
Upload file
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|