Compare commits
6 Commits
v0.1.36
...
v0.1.42-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ce5151e59 | |||
| 66ddc182c9 | |||
| 1524ec4a98 | |||
| 4721950eae | |||
| fba4b9442c | |||
| 48f0e2f64c |
1185
app/package-lock.json
generated
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
@@ -25,13 +27,17 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4",
|
||||
"autoprefixer": "^10",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.7",
|
||||
"vite": "^6"
|
||||
"vite": "^6",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ name = "triple-c"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["image-png"] }
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
|
||||
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 41 KiB |
@@ -120,6 +120,9 @@ pub async fn create_container(
|
||||
|
||||
let mut env_vars: Vec<String> = Vec::new();
|
||||
|
||||
// Tell CLI tools the terminal supports 24-bit RGB color
|
||||
env_vars.push("COLORTERM=truecolor".to_string());
|
||||
|
||||
// Pass host UID/GID so the entrypoint can remap the container user
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -392,6 +395,10 @@ pub async fn stop_container(container_id: &str) -> Result<(), String> {
|
||||
|
||||
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
log::info!(
|
||||
"Removing container {} (v=false: named volumes such as claude config are preserved)",
|
||||
container_id
|
||||
);
|
||||
docker
|
||||
.remove_container(
|
||||
container_id,
|
||||
|
||||
@@ -26,6 +26,14 @@ pub fn run() {
|
||||
settings_store: SettingsStore::new().expect("Failed to initialize settings store"),
|
||||
exec_manager: ExecSessionManager::new(),
|
||||
})
|
||||
.setup(|app| {
|
||||
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png"))
|
||||
.expect("Failed to load window icon");
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_icon(icon);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
let state = window.state::<AppState>();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||
"productName": "Triple-C",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.triple-c.app",
|
||||
"identifier": "com.triple-c.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
|
||||
54
app/src/components/layout/Sidebar.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
// Mock zustand store
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: vi.fn((selector) =>
|
||||
selector({
|
||||
sidebarView: "projects",
|
||||
setSidebarView: vi.fn(),
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock child components to isolate Sidebar layout testing
|
||||
vi.mock("../projects/ProjectList", () => ({
|
||||
default: () => <div data-testid="project-list">ProjectList</div>,
|
||||
}));
|
||||
vi.mock("../settings/SettingsPanel", () => ({
|
||||
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
||||
}));
|
||||
|
||||
describe("Sidebar", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the sidebar with content area", () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText("Projects")).toBeInTheDocument();
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("content area has min-w-0 to prevent flex overflow", () => {
|
||||
const { container } = render(<Sidebar />);
|
||||
const contentArea = container.querySelector(".overflow-y-auto");
|
||||
expect(contentArea).not.toBeNull();
|
||||
expect(contentArea!.className).toContain("min-w-0");
|
||||
});
|
||||
|
||||
it("content area has overflow-x-hidden to prevent horizontal scroll", () => {
|
||||
const { container } = render(<Sidebar />);
|
||||
const contentArea = container.querySelector(".overflow-y-auto");
|
||||
expect(contentArea).not.toBeNull();
|
||||
expect(contentArea!.className).toContain("overflow-x-hidden");
|
||||
});
|
||||
|
||||
it("sidebar outer container has overflow-hidden", () => {
|
||||
const { container } = render(<Sidebar />);
|
||||
const sidebar = container.firstElementChild;
|
||||
expect(sidebar).not.toBeNull();
|
||||
expect(sidebar!.className).toContain("overflow-hidden");
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ export default function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
||||
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
128
app/src/components/projects/ProjectCard.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import ProjectCard from "./ProjectCard";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
// Mock Tauri dialog plugin
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
const mockUpdate = vi.fn();
|
||||
const mockStart = vi.fn();
|
||||
const mockStop = vi.fn();
|
||||
const mockRebuild = vi.fn();
|
||||
const mockRemove = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useProjects", () => ({
|
||||
useProjects: () => ({
|
||||
start: mockStart,
|
||||
stop: mockStop,
|
||||
rebuild: mockRebuild,
|
||||
remove: mockRemove,
|
||||
update: mockUpdate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTerminal", () => ({
|
||||
useTerminal: () => ({
|
||||
open: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockSelectedProjectId: string | null = null;
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: vi.fn((selector) =>
|
||||
selector({
|
||||
selectedProjectId: mockSelectedProjectId,
|
||||
setSelectedProject: vi.fn(),
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const mockProject: Project = {
|
||||
id: "test-1",
|
||||
name: "Test Project",
|
||||
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
auth_mode: "login",
|
||||
bedrock_config: null,
|
||||
allow_docker_access: false,
|
||||
ssh_key_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
claude_instructions: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("ProjectCard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSelectedProjectId = null;
|
||||
});
|
||||
|
||||
it("renders project name and path", () => {
|
||||
render(<ProjectCard project={mockProject} />);
|
||||
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
||||
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("card root has min-w-0 and overflow-hidden to contain content", () => {
|
||||
const { container } = render(<ProjectCard project={mockProject} />);
|
||||
const card = container.firstElementChild;
|
||||
expect(card).not.toBeNull();
|
||||
expect(card!.className).toContain("min-w-0");
|
||||
expect(card!.className).toContain("overflow-hidden");
|
||||
});
|
||||
|
||||
describe("when selected and showing config", () => {
|
||||
beforeEach(() => {
|
||||
mockSelectedProjectId = "test-1";
|
||||
});
|
||||
|
||||
it("expanded area has min-w-0 and overflow-hidden", () => {
|
||||
const { container } = render(<ProjectCard project={mockProject} />);
|
||||
// The expanded section (mt-2 ml-4) contains the auth/action/config controls
|
||||
const expandedSection = container.querySelector(".ml-4.mt-2");
|
||||
expect(expandedSection).not.toBeNull();
|
||||
expect(expandedSection!.className).toContain("min-w-0");
|
||||
expect(expandedSection!.className).toContain("overflow-hidden");
|
||||
});
|
||||
|
||||
it("folder path inputs use min-w-0 to allow shrinking", async () => {
|
||||
const { container } = render(<ProjectCard project={mockProject} />);
|
||||
|
||||
// Click Config button to show config panel
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Config"));
|
||||
});
|
||||
|
||||
// After config is shown, check the folder host_path input has min-w-0
|
||||
const hostPathInputs = container.querySelectorAll('input[placeholder="/path/to/folder"]');
|
||||
expect(hostPathInputs.length).toBeGreaterThan(0);
|
||||
expect(hostPathInputs[0].className).toContain("min-w-0");
|
||||
});
|
||||
|
||||
it("config panel container has overflow-hidden", async () => {
|
||||
const { container } = render(<ProjectCard project={mockProject} />);
|
||||
|
||||
// Click Config button
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Config"));
|
||||
});
|
||||
|
||||
// The config panel has border-t and overflow containment classes
|
||||
const allDivs = container.querySelectorAll("div");
|
||||
const configPanel = Array.from(allDivs).find(
|
||||
(div) => div.className.includes("border-t") && div.className.includes("min-w-0")
|
||||
);
|
||||
expect(configPanel).toBeDefined();
|
||||
expect(configPanel!.className).toContain("overflow-hidden");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -255,7 +255,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => setSelectedProject(project.id)}
|
||||
className={`px-3 py-2 rounded cursor-pointer transition-colors ${
|
||||
className={`px-3 py-2 rounded cursor-pointer transition-colors min-w-0 overflow-hidden ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-tertiary)]"
|
||||
: "hover:bg-[var(--bg-tertiary)]"
|
||||
@@ -269,14 +269,12 @@ export default function ProjectCard({ project }: Props) {
|
||||
{project.paths.map((pp, i) => (
|
||||
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
|
||||
<span className="font-mono">/workspace/{pp.mount_name}</span>
|
||||
<span className="mx-1">←</span>
|
||||
<span>{pp.host_path}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="mt-2 ml-4 space-y-2">
|
||||
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
|
||||
{/* Auth mode selector */}
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||
@@ -359,78 +357,82 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Config panel */}
|
||||
{showConfig && (
|
||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)] min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Folder paths */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
|
||||
{paths.map((pp, i) => (
|
||||
<div key={i} className="flex gap-1 mb-1 items-center">
|
||||
<input
|
||||
value={pp.host_path}
|
||||
onChange={(e) => {
|
||||
const updated = [...paths];
|
||||
updated[i] = { ...updated[i], host_path: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try { await update({ ...project, paths }); } catch (err) {
|
||||
console.error("Failed to update paths:", err);
|
||||
}
|
||||
}}
|
||||
placeholder="/path/to/folder"
|
||||
disabled={!isStopped}
|
||||
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") {
|
||||
<div key={i} className="mb-1">
|
||||
<div className="flex gap-1 items-center min-w-0">
|
||||
<input
|
||||
value={pp.host_path}
|
||||
onChange={(e) => {
|
||||
const updated = [...paths];
|
||||
const basename = selected.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
|
||||
updated[i] = { host_path: selected, mount_name: updated[i].mount_name || basename };
|
||||
updated[i] = { ...updated[i], host_path: e.target.value };
|
||||
setPaths(updated);
|
||||
try { await update({ ...project, paths: updated }); } catch (err) {
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try { await update({ ...project, paths }); } catch (err) {
|
||||
console.error("Failed to update paths:", err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!isStopped}
|
||||
className="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
...
|
||||
</button>
|
||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
|
||||
<input
|
||||
value={pp.mount_name}
|
||||
onChange={(e) => {
|
||||
const updated = [...paths];
|
||||
updated[i] = { ...updated[i], mount_name: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try { await update({ ...project, paths }); } catch (err) {
|
||||
console.error("Failed to update paths:", err);
|
||||
}
|
||||
}}
|
||||
placeholder="name"
|
||||
disabled={!isStopped}
|
||||
className="w-20 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||
/>
|
||||
{paths.length > 1 && (
|
||||
}}
|
||||
placeholder="/path/to/folder"
|
||||
disabled={!isStopped}
|
||||
className="flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const updated = paths.filter((_, j) => j !== i);
|
||||
setPaths(updated);
|
||||
try { await update({ ...project, paths: updated }); } catch (err) {
|
||||
console.error("Failed to remove path:", err);
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") {
|
||||
const updated = [...paths];
|
||||
const basename = selected.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
|
||||
updated[i] = { host_path: selected, mount_name: updated[i].mount_name || basename };
|
||||
setPaths(updated);
|
||||
try { await update({ ...project, paths: updated }); } catch (err) {
|
||||
console.error("Failed to update paths:", err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!isStopped}
|
||||
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
||||
className="flex-shrink-0 px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
x
|
||||
...
|
||||
</button>
|
||||
)}
|
||||
{paths.length > 1 && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const updated = paths.filter((_, j) => j !== i);
|
||||
setPaths(updated);
|
||||
try { await update({ ...project, paths: updated }); } catch (err) {
|
||||
console.error("Failed to remove path:", err);
|
||||
}
|
||||
}}
|
||||
disabled={!isStopped}
|
||||
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 items-center mt-0.5 min-w-0">
|
||||
<span className="text-xs text-[var(--text-secondary)]">/workspace/</span>
|
||||
<input
|
||||
value={pp.mount_name}
|
||||
onChange={(e) => {
|
||||
const updated = [...paths];
|
||||
updated[i] = { ...updated[i], mount_name: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try { await update({ ...project, paths }); } catch (err) {
|
||||
console.error("Failed to update paths:", err);
|
||||
}
|
||||
}}
|
||||
placeholder="name"
|
||||
disabled={!isStopped}
|
||||
className="flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
|
||||
@@ -7,11 +7,6 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
|
||||
/** Strip ANSI escape sequences from a string. */
|
||||
function stripAnsi(s: string): string {
|
||||
return s.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07/g, "");
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
active: boolean;
|
||||
@@ -21,6 +16,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,13 +64,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
term.open(containerRef.current);
|
||||
|
||||
// Try WebGL renderer, fall back silently
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
term.loadAddon(webglAddon);
|
||||
} catch {
|
||||
// WebGL not available, canvas renderer is fine
|
||||
}
|
||||
// WebGL addon is loaded/disposed dynamically in the active effect
|
||||
// to avoid exhausting the browser's limited WebGL context pool.
|
||||
|
||||
fitAddon.fit();
|
||||
termRef.current = term;
|
||||
@@ -88,50 +79,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
sendInput(sessionId, data);
|
||||
});
|
||||
|
||||
// ── URL accumulator ──────────────────────────────────────────────
|
||||
// Claude Code login emits a long OAuth URL that gets split across
|
||||
// hard newlines (\n / \r\n). The WebLinksAddon only joins
|
||||
// soft-wrapped lines (the `isWrapped` flag), so the URL match is
|
||||
// truncated and the link fails when clicked.
|
||||
//
|
||||
// Fix: buffer recent output, strip ANSI codes, and after a short
|
||||
// debounce check for a URL that spans multiple lines. When found,
|
||||
// write a single clean clickable copy to the terminal.
|
||||
const textDecoder = new TextDecoder();
|
||||
let outputBuffer = "";
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flushUrlBuffer = () => {
|
||||
const plain = stripAnsi(outputBuffer);
|
||||
// Reassemble: strip hard newlines and carriage returns to join
|
||||
// fragments that were split across terminal lines.
|
||||
const joined = plain.replace(/[\r\n]+/g, "");
|
||||
// Look for a long OAuth/auth URL (Claude login URLs contain
|
||||
// "oauth" or "console.anthropic.com" or "/authorize").
|
||||
const match = joined.match(/https?:\/\/[^\s'"\x07]{80,}/);
|
||||
if (match) {
|
||||
const url = match[0];
|
||||
term.write("\r\n\x1b[36m🔗 Clickable login URL:\x1b[0m\r\n");
|
||||
term.write(`\x1b[4;34m${url}\x1b[0m\r\n`);
|
||||
}
|
||||
outputBuffer = "";
|
||||
};
|
||||
|
||||
// Handle backend output -> terminal
|
||||
let aborted = false;
|
||||
|
||||
const outputPromise = onOutput(sessionId, (data) => {
|
||||
if (aborted) return;
|
||||
term.write(data);
|
||||
|
||||
// Accumulate for URL detection (data is a Uint8Array, so decode it)
|
||||
outputBuffer += textDecoder.decode(data);
|
||||
// Cap buffer size to avoid memory growth
|
||||
if (outputBuffer.length > 8192) {
|
||||
outputBuffer = outputBuffer.slice(-4096);
|
||||
}
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(flushUrlBuffer, 150);
|
||||
}).then((unlisten) => {
|
||||
if (aborted) unlisten();
|
||||
return unlisten;
|
||||
@@ -145,12 +98,16 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
return unlisten;
|
||||
});
|
||||
|
||||
// Handle resize (throttled via requestAnimationFrame to avoid excessive calls)
|
||||
// Handle resize (throttled via requestAnimationFrame to avoid excessive calls).
|
||||
// Skip resize work for hidden terminals — containerRef will have 0 dimensions.
|
||||
let resizeRafId: number | null = null;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeRafId !== null) return;
|
||||
const el = containerRef.current;
|
||||
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
||||
resizeRafId = requestAnimationFrame(() => {
|
||||
resizeRafId = null;
|
||||
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||
fitAddon.fit();
|
||||
resize(sessionId, term.cols, term.rows);
|
||||
});
|
||||
@@ -159,21 +116,47 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
inputDisposable.dispose();
|
||||
outputPromise.then((fn) => fn?.());
|
||||
exitPromise.then((fn) => fn?.());
|
||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||
resizeObserver.disconnect();
|
||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||
webglRef.current = null;
|
||||
term.dispose();
|
||||
};
|
||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-fit when tab becomes active
|
||||
// Manage WebGL lifecycle and re-fit when tab becomes active.
|
||||
// Only the active terminal holds a WebGL context to avoid exhausting
|
||||
// the browser's limited pool (~8-16 contexts).
|
||||
useEffect(() => {
|
||||
if (active && fitRef.current && termRef.current) {
|
||||
fitRef.current.fit();
|
||||
termRef.current.focus();
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
if (active) {
|
||||
// Attach WebGL renderer
|
||||
if (!webglRef.current) {
|
||||
try {
|
||||
const addon = new WebglAddon();
|
||||
addon.onContextLoss(() => {
|
||||
try { addon.dispose(); } catch { /* ignore */ }
|
||||
webglRef.current = null;
|
||||
});
|
||||
term.loadAddon(addon);
|
||||
webglRef.current = addon;
|
||||
} catch {
|
||||
// WebGL not available, canvas renderer is fine
|
||||
}
|
||||
}
|
||||
fitRef.current?.fit();
|
||||
term.focus();
|
||||
} else {
|
||||
// Release WebGL context for inactive terminals
|
||||
if (webglRef.current) {
|
||||
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||
webglRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
|
||||
36
app/src/test/icon-config.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
describe("Window icon configuration", () => {
|
||||
const srcTauriDir = resolve(__dirname, "../../src-tauri");
|
||||
|
||||
it("lib.rs sets window icon using set_icon in setup hook", () => {
|
||||
const libRs = readFileSync(resolve(srcTauriDir, "src/lib.rs"), "utf-8");
|
||||
expect(libRs).toContain("set_icon");
|
||||
expect(libRs).toContain("icon.png");
|
||||
});
|
||||
|
||||
it("Cargo.toml enables image-png feature for icon loading", () => {
|
||||
const cargoToml = readFileSync(resolve(srcTauriDir, "Cargo.toml"), "utf-8");
|
||||
expect(cargoToml).toContain("image-png");
|
||||
});
|
||||
|
||||
it("icon.png exists in the icons directory", () => {
|
||||
const iconPath = resolve(srcTauriDir, "icons/icon.png");
|
||||
expect(existsSync(iconPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("icon.ico exists in the icons directory for Windows", () => {
|
||||
const icoPath = resolve(srcTauriDir, "icons/icon.ico");
|
||||
expect(existsSync(icoPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("tauri.conf.json includes icon.ico in bundle icons", () => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(resolve(srcTauriDir, "tauri.conf.json"), "utf-8")
|
||||
);
|
||||
expect(config.bundle.icon).toContain("icons/icon.ico");
|
||||
expect(config.bundle.icon).toContain("icons/icon.png");
|
||||
});
|
||||
});
|
||||
1
app/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -17,5 +17,6 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
||||
}
|
||||
|
||||
11
app/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
},
|
||||
});
|
||||