Compare commits
3 Commits
v0.1.89
...
v0.1.92-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| bf8ef3dba1 | |||
| 418afe00ed | |||
| ab16ac11e7 |
211
app/src-tauri/src/commands/file_commands.rs
Normal file
211
app/src-tauri/src/commands/file_commands.rs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::docker::client::get_docker;
|
||||||
|
use crate::docker::exec::exec_oneshot;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub is_directory: bool,
|
||||||
|
pub size: u64,
|
||||||
|
pub modified: String,
|
||||||
|
pub permissions: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_container_files(
|
||||||
|
project_id: String,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<FileEntry>, String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
|
let cmd = vec![
|
||||||
|
"find".to_string(),
|
||||||
|
path.clone(),
|
||||||
|
"-mindepth".to_string(),
|
||||||
|
"1".to_string(),
|
||||||
|
"-maxdepth".to_string(),
|
||||||
|
"1".to_string(),
|
||||||
|
"-printf".to_string(),
|
||||||
|
"%f\t%y\t%s\t%T@\t%m\n".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let output = exec_oneshot(container_id, cmd).await?;
|
||||||
|
|
||||||
|
let mut entries: Vec<FileEntry> = output
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.filter_map(|line| {
|
||||||
|
let parts: Vec<&str> = line.split('\t').collect();
|
||||||
|
if parts.len() < 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = parts[0].to_string();
|
||||||
|
let is_directory = parts[1] == "d";
|
||||||
|
let size = parts[2].parse::<u64>().unwrap_or(0);
|
||||||
|
let modified_epoch = parts[3].parse::<f64>().unwrap_or(0.0);
|
||||||
|
let permissions = parts[4].to_string();
|
||||||
|
|
||||||
|
// Convert epoch to ISO-ish string
|
||||||
|
let modified = {
|
||||||
|
let secs = modified_epoch as i64;
|
||||||
|
let dt = chrono::DateTime::from_timestamp(secs, 0)
|
||||||
|
.unwrap_or_default();
|
||||||
|
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let entry_path = if path.ends_with('/') {
|
||||||
|
format!("{}{}", path, name)
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", path, name)
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(FileEntry {
|
||||||
|
name,
|
||||||
|
path: entry_path,
|
||||||
|
is_directory,
|
||||||
|
size,
|
||||||
|
modified,
|
||||||
|
permissions,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort: directories first, then alphabetical
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
b.is_directory
|
||||||
|
.cmp(&a.is_directory)
|
||||||
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn download_container_file(
|
||||||
|
project_id: String,
|
||||||
|
container_path: String,
|
||||||
|
host_path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let mut stream = docker.download_from_container(
|
||||||
|
container_id,
|
||||||
|
Some(DownloadFromContainerOptions {
|
||||||
|
path: container_path.clone(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut tar_bytes = Vec::new();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk.map_err(|e| format!("Failed to download file: {}", e))?;
|
||||||
|
tar_bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract single file from tar archive
|
||||||
|
let mut archive = tar::Archive::new(&tar_bytes[..]);
|
||||||
|
let mut found = false;
|
||||||
|
for entry in archive
|
||||||
|
.entries()
|
||||||
|
.map_err(|e| format!("Failed to read tar entries: {}", e))?
|
||||||
|
{
|
||||||
|
let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {}", e))?;
|
||||||
|
let mut contents = Vec::new();
|
||||||
|
std::io::Read::read_to_end(&mut entry, &mut contents)
|
||||||
|
.map_err(|e| format!("Failed to read file contents: {}", e))?;
|
||||||
|
std::fs::write(&host_path, &contents)
|
||||||
|
.map_err(|e| format!("Failed to write file to host: {}", e))?;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return Err("File not found in tar archive".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upload_file_to_container(
|
||||||
|
project_id: String,
|
||||||
|
host_path: String,
|
||||||
|
container_dir: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let file_data = std::fs::read(&host_path)
|
||||||
|
.map_err(|e| format!("Failed to read host file: {}", e))?;
|
||||||
|
|
||||||
|
let file_name = std::path::Path::new(&host_path)
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| "Invalid file path".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Build tar archive in memory
|
||||||
|
let mut tar_buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(file_data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, &file_name, &file_data[..])
|
||||||
|
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
docker
|
||||||
|
.upload_to_container(
|
||||||
|
container_id,
|
||||||
|
Some(UploadToContainerOptions {
|
||||||
|
path: container_dir,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
tar_buf.into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
|
pub mod file_commands;
|
||||||
pub mod mcp_commands;
|
pub mod mcp_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ exec claude --dangerously-skip-permissions
|
|||||||
pub async fn open_terminal_session(
|
pub async fn open_terminal_session(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
session_type: Option<String>,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -86,7 +87,10 @@ pub async fn open_terminal_session(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "Container not running".to_string())?;
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
let cmd = build_terminal_cmd(&project, &state);
|
let cmd = match session_type.as_deref() {
|
||||||
|
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||||
|
_ => build_terminal_cmd(&project, &state),
|
||||||
|
};
|
||||||
|
|
||||||
let output_event = format!("terminal-output-{}", session_id);
|
let output_event = format!("terminal-output-{}", session_id);
|
||||||
let exit_event = format!("terminal-exit-{}", session_id);
|
let exit_event = format!("terminal-exit-{}", session_id);
|
||||||
|
|||||||
@@ -277,3 +277,41 @@ impl ExecSessionManager {
|
|||||||
Ok(format!("/tmp/{}", file_name))
|
Ok(format!("/tmp/{}", file_name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||||
|
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let exec = docker
|
||||||
|
.create_exec(
|
||||||
|
container_id,
|
||||||
|
CreateExecOptions {
|
||||||
|
attach_stdout: Some(true),
|
||||||
|
attach_stderr: Some(true),
|
||||||
|
cmd: Some(cmd),
|
||||||
|
user: Some("claude".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||||
|
|
||||||
|
let result = docker
|
||||||
|
.start_exec(&exec.id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
StartExecResults::Attached { mut output, .. } => {
|
||||||
|
let mut stdout = String::new();
|
||||||
|
while let Some(msg) = output.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(data) => stdout.push_str(&String::from_utf8_lossy(&data.into_bytes())),
|
||||||
|
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(stdout)
|
||||||
|
}
|
||||||
|
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ pub fn run() {
|
|||||||
commands::terminal_commands::start_audio_bridge,
|
commands::terminal_commands::start_audio_bridge,
|
||||||
commands::terminal_commands::send_audio_data,
|
commands::terminal_commands::send_audio_data,
|
||||||
commands::terminal_commands::stop_audio_bridge,
|
commands::terminal_commands::stop_audio_bridge,
|
||||||
|
// Files
|
||||||
|
commands::file_commands::list_container_files,
|
||||||
|
commands::file_commands::download_container_file,
|
||||||
|
commands::file_commands::upload_file_to_container,
|
||||||
// MCP
|
// MCP
|
||||||
commands::mcp_commands::list_mcp_servers,
|
commands::mcp_commands::list_mcp_servers,
|
||||||
commands::mcp_commands::add_mcp_server,
|
commands::mcp_commands::add_mcp_server,
|
||||||
|
|||||||
55
app/src/components/projects/ConfirmRemoveModal.tsx
Normal file
55
app/src/components/projects/ConfirmRemoveModal.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectName: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current) onCancel();
|
||||||
|
},
|
||||||
|
[onCancel],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 p-6 w-[24rem] shadow-xl">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-5">
|
||||||
|
Are you sure you want to remove <strong className="text-[var(--text-primary)]">{projectName}</strong>? This will delete the container, config volume, and stored credentials.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-2 text-sm text-white bg-[var(--error)] hover:opacity-80 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,8 @@ import EnvVarsModal from "./EnvVarsModal";
|
|||||||
import PortMappingsModal from "./PortMappingsModal";
|
import PortMappingsModal from "./PortMappingsModal";
|
||||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||||
import ContainerProgressModal from "./ContainerProgressModal";
|
import ContainerProgressModal from "./ContainerProgressModal";
|
||||||
|
import FileManagerModal from "./FileManagerModal";
|
||||||
|
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -27,10 +29,11 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||||
|
const [showFileManager, setShowFileManager] = useState(false);
|
||||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||||
const [operationCompleted, setOperationCompleted] = useState(false);
|
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||||
const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);
|
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [editName, setEditName] = useState(project.name);
|
const [editName, setEditName] = useState(project.name);
|
||||||
const isSelected = selectedProjectId === project.id;
|
const isSelected = selectedProjectId === project.id;
|
||||||
@@ -139,6 +142,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 () => {
|
const handleForceStop = async () => {
|
||||||
try {
|
try {
|
||||||
await stop(project.id);
|
await stop(project.id);
|
||||||
@@ -409,6 +420,8 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<>
|
<>
|
||||||
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
||||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||||
|
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
|
||||||
|
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -423,34 +436,12 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
disabled={false}
|
disabled={false}
|
||||||
label={showConfig ? "Hide" : "Config"}
|
label={showConfig ? "Hide" : "Config"}
|
||||||
/>
|
/>
|
||||||
{showRemoveConfirm ? (
|
<ActionButton
|
||||||
<span className="inline-flex items-center gap-1 text-xs">
|
onClick={() => setShowRemoveModal(true)}
|
||||||
<span className="text-[var(--text-secondary)]">Remove?</span>
|
disabled={loading}
|
||||||
<button
|
label="Remove"
|
||||||
onClick={async (e) => {
|
danger
|
||||||
e.stopPropagation();
|
/>
|
||||||
setShowRemoveConfirm(false);
|
|
||||||
await remove(project.id);
|
|
||||||
}}
|
|
||||||
className="px-1.5 py-0.5 rounded text-white bg-[var(--error)] hover:opacity-80 transition-colors"
|
|
||||||
>
|
|
||||||
Yes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); setShowRemoveConfirm(false); }}
|
|
||||||
className="px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
No
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<ActionButton
|
|
||||||
onClick={() => setShowRemoveConfirm(true)}
|
|
||||||
disabled={loading}
|
|
||||||
label="Remove"
|
|
||||||
danger
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Config panel */}
|
{/* Config panel */}
|
||||||
@@ -905,6 +896,25 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showFileManager && (
|
||||||
|
<FileManagerModal
|
||||||
|
projectId={project.id}
|
||||||
|
projectName={project.name}
|
||||||
|
onClose={() => setShowFileManager(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRemoveModal && (
|
||||||
|
<ConfirmRemoveModal
|
||||||
|
projectName={project.name}
|
||||||
|
onConfirm={async () => {
|
||||||
|
setShowRemoveModal(false);
|
||||||
|
await remove(project.id);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowRemoveModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeOperation && (
|
{activeOperation && (
|
||||||
<ContainerProgressModal
|
<ContainerProgressModal
|
||||||
projectName={project.name}
|
projectName={project.name}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export default function TerminalTabs() {
|
|||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
: "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
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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(
|
const open = useCallback(
|
||||||
async (projectId: string, projectName: string) => {
|
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude") => {
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
await commands.openTerminalSession(projectId, sessionId);
|
await commands.openTerminalSession(projectId, sessionId, sessionType);
|
||||||
addSession({ id: sessionId, projectId, projectName });
|
addSession({ id: sessionId, projectId, projectName, sessionType });
|
||||||
return sessionId;
|
return sessionId;
|
||||||
},
|
},
|
||||||
[addSession],
|
[addSession],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -39,8 +39,8 @@ export const detectHostTimezone = () =>
|
|||||||
invoke<string>("detect_host_timezone");
|
invoke<string>("detect_host_timezone");
|
||||||
|
|
||||||
// Terminal
|
// Terminal
|
||||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) =>
|
||||||
invoke<void>("open_terminal_session", { projectId, sessionId });
|
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType });
|
||||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||||
invoke<void>("terminal_input", { sessionId, data });
|
invoke<void>("terminal_input", { sessionId, data });
|
||||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||||
@@ -65,6 +65,14 @@ export const updateMcpServer = (server: McpServer) =>
|
|||||||
export const removeMcpServer = (serverId: string) =>
|
export const removeMcpServer = (serverId: string) =>
|
||||||
invoke<void>("remove_mcp_server", { serverId });
|
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
|
// Updates
|
||||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||||
export const checkForUpdates = () =>
|
export const checkForUpdates = () =>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export interface TerminalSession {
|
|||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
sessionType: "claude" | "bash";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageSource = "registry" | "local_build" | "custom";
|
export type ImageSource = "registry" | "local_build" | "custom";
|
||||||
@@ -135,3 +136,12 @@ export interface McpServer {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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