Compare commits
3 Commits
v0.1.73
...
v0.1.76-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ddc705925 | |||
| 1aced2d860 | |||
| 652e451afe |
@@ -1,10 +1,20 @@
|
|||||||
use tauri::State;
|
use tauri::{Emitter, State};
|
||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus};
|
use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus};
|
||||||
use crate::storage::secure;
|
use crate::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
|
||||||
|
let _ = app_handle.emit(
|
||||||
|
"container-progress",
|
||||||
|
serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"message": message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract secret fields from a project and store them in the OS keychain.
|
/// Extract secret fields from a project and store them in the OS keychain.
|
||||||
fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
||||||
if let Some(ref token) = project.git_token {
|
if let Some(ref token) = project.git_token {
|
||||||
@@ -116,6 +126,7 @@ pub async fn update_project(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_project_container(
|
pub async fn start_project_container(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
let mut project = state
|
let mut project = state
|
||||||
@@ -147,6 +158,7 @@ pub async fn start_project_container(
|
|||||||
// Wrap container operations so that any failure resets status to Stopped.
|
// Wrap container operations so that any failure resets status to Stopped.
|
||||||
let result: Result<String, String> = async {
|
let result: Result<String, String> = async {
|
||||||
// Ensure image exists
|
// Ensure image exists
|
||||||
|
emit_progress(&app_handle, &project_id, "Checking image...");
|
||||||
if !docker::image_exists(&image_name).await? {
|
if !docker::image_exists(&image_name).await? {
|
||||||
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||||
}
|
}
|
||||||
@@ -173,9 +185,11 @@ pub async fn start_project_container(
|
|||||||
if needs_recreate {
|
if needs_recreate {
|
||||||
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
||||||
// Snapshot the filesystem before destroying
|
// Snapshot the filesystem before destroying
|
||||||
|
emit_progress(&app_handle, &project_id, "Saving container state...");
|
||||||
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
|
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
|
||||||
log::warn!("Failed to snapshot container before recreation: {}", e);
|
log::warn!("Failed to snapshot container before recreation: {}", e);
|
||||||
}
|
}
|
||||||
|
emit_progress(&app_handle, &project_id, "Recreating container...");
|
||||||
let _ = docker::stop_container(&existing_id).await;
|
let _ = docker::stop_container(&existing_id).await;
|
||||||
docker::remove_container(&existing_id).await?;
|
docker::remove_container(&existing_id).await?;
|
||||||
|
|
||||||
@@ -197,9 +211,11 @@ pub async fn start_project_container(
|
|||||||
&settings.global_custom_env_vars,
|
&settings.global_custom_env_vars,
|
||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
} else {
|
} else {
|
||||||
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&existing_id).await?;
|
docker::start_container(&existing_id).await?;
|
||||||
existing_id
|
existing_id
|
||||||
}
|
}
|
||||||
@@ -215,6 +231,7 @@ pub async fn start_project_container(
|
|||||||
image_name.clone()
|
image_name.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
emit_progress(&app_handle, &project_id, "Creating container...");
|
||||||
let new_id = docker::create_container(
|
let new_id = docker::create_container(
|
||||||
&project,
|
&project,
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
@@ -225,6 +242,7 @@ pub async fn start_project_container(
|
|||||||
&settings.global_custom_env_vars,
|
&settings.global_custom_env_vars,
|
||||||
settings.timezone.as_deref(),
|
settings.timezone.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
|
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
};
|
};
|
||||||
@@ -252,6 +270,7 @@ pub async fn start_project_container(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_project_container(
|
pub async fn stop_project_container(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let project = state
|
let project = state
|
||||||
@@ -259,22 +278,26 @@ pub async fn stop_project_container(
|
|||||||
.get(&project_id)
|
.get(&project_id)
|
||||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
if let Some(ref container_id) = project.container_id {
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
|
||||||
|
|
||||||
|
if let Some(ref container_id) = project.container_id {
|
||||||
// Close exec sessions for this project
|
// Close exec sessions for this project
|
||||||
|
emit_progress(&app_handle, &project_id, "Stopping container...");
|
||||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
|
|
||||||
docker::stop_container(container_id).await?;
|
if let Err(e) = docker::stop_container(container_id).await {
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn rebuild_project_container(
|
pub async fn rebuild_project_container(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
let project = state
|
let project = state
|
||||||
@@ -299,7 +322,7 @@ pub async fn rebuild_project_container(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start fresh
|
// Start fresh
|
||||||
start_project_container(project_id, state).await
|
start_project_container(project_id, app_handle, state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_docker_socket() -> String {
|
fn default_docker_socket() -> String {
|
||||||
|
|||||||
@@ -70,17 +70,38 @@ impl ProjectsStore {
|
|||||||
(Vec::new(), false)
|
(Vec::new(), false)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reconcile stale transient statuses: on a cold app start no Docker
|
||||||
|
// operations can be in flight, so Starting/Stopping are always stale.
|
||||||
|
let mut projects = projects;
|
||||||
|
let mut needs_save = needs_save;
|
||||||
|
for p in projects.iter_mut() {
|
||||||
|
match p.status {
|
||||||
|
crate::models::ProjectStatus::Starting | crate::models::ProjectStatus::Stopping => {
|
||||||
|
log::warn!(
|
||||||
|
"Reconciling stale '{}' status for project '{}' ({}) → Stopped",
|
||||||
|
serde_json::to_string(&p.status).unwrap_or_default().trim_matches('"'),
|
||||||
|
p.name,
|
||||||
|
p.id
|
||||||
|
);
|
||||||
|
p.status = crate::models::ProjectStatus::Stopped;
|
||||||
|
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
needs_save = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let store = Self {
|
let store = Self {
|
||||||
projects: Mutex::new(projects),
|
projects: Mutex::new(projects),
|
||||||
file_path,
|
file_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Persist migrated format back to disk
|
// Persist migrated/reconciled format back to disk
|
||||||
if needs_save {
|
if needs_save {
|
||||||
log::info!("Migrated projects.json from single-path to multi-path format");
|
log::info!("Saving reconciled/migrated projects.json to disk");
|
||||||
let projects = store.lock();
|
let projects = store.lock();
|
||||||
if let Err(e) = store.save(&projects) {
|
if let Err(e) = store.save(&projects) {
|
||||||
log::error!("Failed to save migrated projects: {}", e);
|
log::error!("Failed to save projects: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
109
app/src/components/projects/ContainerProgressModal.tsx
Normal file
109
app/src/components/projects/ContainerProgressModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectName: string;
|
||||||
|
operation: "starting" | "stopping" | "resetting";
|
||||||
|
progressMsg: string | null;
|
||||||
|
error: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
onForceStop: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationLabels: Record<string, string> = {
|
||||||
|
starting: "Starting",
|
||||||
|
stopping: "Stopping",
|
||||||
|
resetting: "Resetting",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContainerProgressModal({
|
||||||
|
projectName,
|
||||||
|
operation,
|
||||||
|
progressMsg,
|
||||||
|
error,
|
||||||
|
completed,
|
||||||
|
onForceStop,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-close on success after 800ms
|
||||||
|
useEffect(() => {
|
||||||
|
if (completed && !error) {
|
||||||
|
const timer = setTimeout(onClose, 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [completed, error, onClose]);
|
||||||
|
|
||||||
|
// Escape to close (only when completed or error)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && (completed || error)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [completed, error, onClose]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current && (completed || error)) onClose();
|
||||||
|
},
|
||||||
|
[completed, error, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const inProgress = !completed && !error;
|
||||||
|
|
||||||
|
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-80 shadow-xl text-center">
|
||||||
|
<h3 className="text-sm font-semibold mb-4">
|
||||||
|
{operationLabels[operation]} “{projectName}”
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Spinner / checkmark / error icon */}
|
||||||
|
<div className="flex justify-center mb-3">
|
||||||
|
{error ? (
|
||||||
|
<span className="text-3xl text-[var(--error)]">✕</span>
|
||||||
|
) : completed ? (
|
||||||
|
<span className="text-3xl text-[var(--success)]">✓</span>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress message */}
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
|
||||||
|
{error
|
||||||
|
? <span className="text-[var(--error)]">{error}</span>
|
||||||
|
: completed
|
||||||
|
? "Done!"
|
||||||
|
: progressMsg ?? `${operationLabels[operation]}...`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
{inProgress && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
|
||||||
|
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
|
||||||
|
>
|
||||||
|
Force Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(completed || error) && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||||
|
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
@@ -7,6 +8,7 @@ import { useAppState } from "../../store/appState";
|
|||||||
import EnvVarsModal from "./EnvVarsModal";
|
import EnvVarsModal from "./EnvVarsModal";
|
||||||
import PortMappingsModal from "./PortMappingsModal";
|
import PortMappingsModal from "./PortMappingsModal";
|
||||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||||
|
import ContainerProgressModal from "./ContainerProgressModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -23,6 +25,9 @@ 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 [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||||
|
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||||
|
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||||
const isSelected = selectedProjectId === project.id;
|
const isSelected = selectedProjectId === project.id;
|
||||||
const isStopped = project.status === "stopped" || project.status === "error";
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
|
||||||
@@ -64,9 +69,38 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
setBedrockModelId(project.bedrock_config?.model_id ?? "");
|
setBedrockModelId(project.bedrock_config?.model_id ?? "");
|
||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
|
// Listen for container progress events
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<{ project_id: string; message: string }>(
|
||||||
|
"container-progress",
|
||||||
|
(event) => {
|
||||||
|
if (event.payload.project_id === project.id) {
|
||||||
|
setProgressMsg(event.payload.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => { unlisten.then((f) => f()); };
|
||||||
|
}, [project.id]);
|
||||||
|
|
||||||
|
// Mark operation completed when status settles
|
||||||
|
useEffect(() => {
|
||||||
|
if (project.status === "running" || project.status === "stopped" || project.status === "error") {
|
||||||
|
if (activeOperation) {
|
||||||
|
setOperationCompleted(true);
|
||||||
|
}
|
||||||
|
// Clear progress if no modal is managing it
|
||||||
|
if (!activeOperation) {
|
||||||
|
setProgressMsg(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [project.status, activeOperation]);
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setActiveOperation("starting");
|
||||||
try {
|
try {
|
||||||
await start(project.id);
|
await start(project.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -79,6 +113,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const handleStop = async () => {
|
const handleStop = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setActiveOperation("stopping");
|
||||||
try {
|
try {
|
||||||
await stop(project.id);
|
await stop(project.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,6 +133,21 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleForceStop = async () => {
|
||||||
|
try {
|
||||||
|
await stop(project.id);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setActiveOperation(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const defaultBedrockConfig: BedrockConfig = {
|
const defaultBedrockConfig: BedrockConfig = {
|
||||||
auth_method: "static_credentials",
|
auth_method: "static_credentials",
|
||||||
aws_region: "us-east-1",
|
aws_region: "us-east-1",
|
||||||
@@ -302,6 +354,10 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setProgressMsg(null);
|
||||||
|
setOperationCompleted(false);
|
||||||
|
setActiveOperation("resetting");
|
||||||
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
|
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}}
|
}}
|
||||||
@@ -315,9 +371,12 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-[var(--text-secondary)]">
|
<>
|
||||||
{project.status}...
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
</span>
|
{progressMsg ?? `${project.status}...`}
|
||||||
|
</span>
|
||||||
|
<ActionButton onClick={handleStop} disabled={loading} label="Force Stop" danger />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
|
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
|
||||||
@@ -722,6 +781,18 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
onClose={() => setShowClaudeInstructionsModal(false)}
|
onClose={() => setShowClaudeInstructionsModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeOperation && (
|
||||||
|
<ContainerProgressModal
|
||||||
|
projectName={project.name}
|
||||||
|
operation={activeOperation}
|
||||||
|
progressMsg={progressMsg}
|
||||||
|
error={error}
|
||||||
|
completed={operationCompleted}
|
||||||
|
onForceStop={handleForceStop}
|
||||||
|
onClose={closeModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -86,6 +87,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track scroll position to show "Jump to Current" button
|
||||||
|
const scrollDisposable = term.onScroll(() => {
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
setIsAtBottom(buf.viewportY >= buf.baseY);
|
||||||
|
});
|
||||||
|
|
||||||
// Handle image paste: intercept paste events with image data,
|
// Handle image paste: intercept paste events with image data,
|
||||||
// upload to the container, and inject the file path into terminal input.
|
// upload to the container, and inject the file path into terminal input.
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
@@ -164,6 +171,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
detector.dispose();
|
detector.dispose();
|
||||||
detectorRef.current = null;
|
detectorRef.current = null;
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
|
scrollDisposable.dispose();
|
||||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
@@ -231,6 +239,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}, [detectedUrl]);
|
}, [detectedUrl]);
|
||||||
|
|
||||||
|
const handleScrollToBottom = useCallback(() => {
|
||||||
|
termRef.current?.scrollToBottom();
|
||||||
|
setIsAtBottom(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={terminalContainerRef}
|
ref={terminalContainerRef}
|
||||||
@@ -251,6 +264,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
{imagePasteMsg}
|
{imagePasteMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isAtBottom && (
|
||||||
|
<button
|
||||||
|
onClick={handleScrollToBottom}
|
||||||
|
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Jump to Current ↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
|
|||||||
@@ -50,31 +50,45 @@ export function useProjects() {
|
|||||||
[removeProjectFromList],
|
[removeProjectFromList],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setOptimisticStatus = useCallback(
|
||||||
|
(id: string, status: "starting" | "stopping") => {
|
||||||
|
const { projects } = useAppState.getState();
|
||||||
|
const project = projects.find((p) => p.id === id);
|
||||||
|
if (project) {
|
||||||
|
updateProjectInList({ ...project, status });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateProjectInList],
|
||||||
|
);
|
||||||
|
|
||||||
const start = useCallback(
|
const start = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setOptimisticStatus(id, "starting");
|
||||||
const updated = await commands.startProjectContainer(id);
|
const updated = await commands.startProjectContainer(id);
|
||||||
updateProjectInList(updated);
|
updateProjectInList(updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
[updateProjectInList],
|
[updateProjectInList, setOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(
|
const stop = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setOptimisticStatus(id, "stopping");
|
||||||
await commands.stopProjectContainer(id);
|
await commands.stopProjectContainer(id);
|
||||||
const list = await commands.listProjects();
|
const list = await commands.listProjects();
|
||||||
setProjects(list);
|
setProjects(list);
|
||||||
},
|
},
|
||||||
[setProjects],
|
[setProjects, setOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rebuild = useCallback(
|
const rebuild = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
setOptimisticStatus(id, "starting");
|
||||||
const updated = await commands.rebuildProjectContainer(id);
|
const updated = await commands.rebuildProjectContainer(id);
|
||||||
updateProjectInList(updated);
|
updateProjectInList(updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
[updateProjectInList],
|
[updateProjectInList, setOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
|
|||||||
Reference in New Issue
Block a user