Fix sidebar content overflow and set correct window taskbar icon
The sidebar config panel content was overflowing its container width, causing project names and directory paths to be clipped. Added min-w-0 and overflow-hidden to flex containers, and restructured folder path config rows to stack vertically instead of cramming into one line. The Windows taskbar was showing a black square because no default window icon was set at runtime. Added default_window_icon() call in the Tauri builder using the app's icon.png. Also adds vitest test infrastructure with tests verifying both fixes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1185
app/package-lock.json
generated
1185
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
@@ -25,13 +27,17 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4",
|
"@tailwindcss/vite": "^4",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4",
|
"@vitejs/plugin-react": "^4",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5.7",
|
"typescript": "^5.7",
|
||||||
"vite": "^6"
|
"vite": "^6",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub fn run() {
|
|||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.default_window_icon(tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")).expect("Failed to load window icon"))
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
|||||||
54
app/src/components/layout/Sidebar.test.tsx
Normal file
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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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 />}
|
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
128
app/src/components/projects/ProjectCard.test.tsx
Normal file
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedProject(project.id)}
|
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
|
isSelected
|
||||||
? "bg-[var(--bg-tertiary)]"
|
? "bg-[var(--bg-tertiary)]"
|
||||||
: "hover:bg-[var(--bg-tertiary)]"
|
: "hover:bg-[var(--bg-tertiary)]"
|
||||||
@@ -274,7 +274,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelected && (
|
{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 */}
|
{/* Auth mode selector */}
|
||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||||
@@ -357,12 +357,13 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{/* Config panel */}
|
{/* Config panel */}
|
||||||
{showConfig && (
|
{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 */}
|
{/* Folder paths */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
|
||||||
{paths.map((pp, i) => (
|
{paths.map((pp, i) => (
|
||||||
<div key={i} className="flex gap-1 mb-1 items-center">
|
<div key={i} className="mb-1">
|
||||||
|
<div className="flex gap-1 items-center min-w-0">
|
||||||
<input
|
<input
|
||||||
value={pp.host_path}
|
value={pp.host_path}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -377,7 +378,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}}
|
}}
|
||||||
placeholder="/path/to/folder"
|
placeholder="/path/to/folder"
|
||||||
disabled={!isStopped}
|
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"
|
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
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -393,11 +394,28 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!isStopped}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
...
|
...
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
|
{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
|
<input
|
||||||
value={pp.mount_name}
|
value={pp.mount_name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -412,23 +430,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}}
|
}}
|
||||||
placeholder="name"
|
placeholder="name"
|
||||||
disabled={!isStopped}
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
{paths.length > 1 && (
|
</div>
|
||||||
<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="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>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
|
|||||||
31
app/src/test/icon-config.test.ts
Normal file
31
app/src/test/icon-config.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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 default_window_icon using the app icon", () => {
|
||||||
|
const libRs = readFileSync(resolve(srcTauriDir, "src/lib.rs"), "utf-8");
|
||||||
|
expect(libRs).toContain("default_window_icon");
|
||||||
|
expect(libRs).toContain("icon.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
1
app/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
11
app/vitest.config.ts
Normal file
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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user