Add bash shell tab and file manager for running containers
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>
This commit is contained in:
197
app/src/components/projects/FileManagerModal.tsx
Normal file
197
app/src/components/projects/FileManagerModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import EnvVarsModal from "./EnvVarsModal";
|
||||
import PortMappingsModal from "./PortMappingsModal";
|
||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||
import ContainerProgressModal from "./ContainerProgressModal";
|
||||
import FileManagerModal from "./FileManagerModal";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -27,6 +28,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||
const [showFileManager, setShowFileManager] = useState(false);
|
||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||
@@ -139,6 +141,14 @@ export default function ProjectCard({ project }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenBashShell = async () => {
|
||||
try {
|
||||
await openTerminal(project.id, project.name, "bash");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceStop = async () => {
|
||||
try {
|
||||
await stop(project.id);
|
||||
@@ -409,6 +419,8 @@ export default function ProjectCard({ project }: Props) {
|
||||
<>
|
||||
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
|
||||
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -905,6 +917,14 @@ export default function ProjectCard({ project }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFileManager && (
|
||||
<FileManagerModal
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
onClose={() => setShowFileManager(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeOperation && (
|
||||
<ContainerProgressModal
|
||||
projectName={project.name}
|
||||
|
||||
@@ -23,7 +23,9 @@ export default function TerminalTabs() {
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate max-w-[120px]">{session.projectName}</span>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
74
app/src/hooks/useFileManager.ts
Normal file
74
app/src/hooks/useFileManager.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
export function useFileManager(projectId: string) {
|
||||
const [currentPath, setCurrentPath] = useState("/workspace");
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const navigate = useCallback(
|
||||
async (path: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commands.listContainerFiles(projectId, path);
|
||||
setEntries(result);
|
||||
setCurrentPath(path);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
if (currentPath === "/") return;
|
||||
const parent = currentPath.replace(/\/[^/]+$/, "") || "/";
|
||||
navigate(parent);
|
||||
}, [currentPath, navigate]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
navigate(currentPath);
|
||||
}, [currentPath, navigate]);
|
||||
|
||||
const downloadFile = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
try {
|
||||
const hostPath = await save({ defaultPath: entry.name });
|
||||
if (!hostPath) return;
|
||||
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(async () => {
|
||||
try {
|
||||
const selected = await openDialog({ multiple: false, directory: false });
|
||||
if (!selected) return;
|
||||
await commands.uploadFileToContainer(projectId, selected as string, currentPath);
|
||||
await navigate(currentPath);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}, [projectId, currentPath, navigate]);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
};
|
||||
}
|
||||
@@ -17,10 +17,10 @@ export function useTerminal() {
|
||||
);
|
||||
|
||||
const open = useCallback(
|
||||
async (projectId: string, projectName: string) => {
|
||||
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude") => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
await commands.openTerminalSession(projectId, sessionId);
|
||||
addSession({ id: sessionId, projectId, projectName });
|
||||
await commands.openTerminalSession(projectId, sessionId, sessionType);
|
||||
addSession({ id: sessionId, projectId, projectName, sessionType });
|
||||
return sessionId;
|
||||
},
|
||||
[addSession],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer, FileEntry } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -39,8 +39,8 @@ export const detectHostTimezone = () =>
|
||||
invoke<string>("detect_host_timezone");
|
||||
|
||||
// Terminal
|
||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId });
|
||||
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType });
|
||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("terminal_input", { sessionId, data });
|
||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||
@@ -65,6 +65,14 @@ export const updateMcpServer = (server: McpServer) =>
|
||||
export const removeMcpServer = (serverId: string) =>
|
||||
invoke<void>("remove_mcp_server", { serverId });
|
||||
|
||||
// Files
|
||||
export const listContainerFiles = (projectId: string, path: string) =>
|
||||
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
|
||||
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
|
||||
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
|
||||
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
|
||||
|
||||
// Updates
|
||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||
export const checkForUpdates = () =>
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface TerminalSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
sessionType: "claude" | "bash";
|
||||
}
|
||||
|
||||
export type ImageSource = "registry" | "local_build" | "custom";
|
||||
@@ -135,3 +136,12 @@ export interface McpServer {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user