Fix sidebar content overflow and set correct window taskbar icon
Some checks failed
Build App / build-windows (push) Failing after 41s
Build App / build-linux (push) Failing after 1m25s

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:
2026-02-28 22:53:30 +00:00
parent 4721950eae
commit 1524ec4a98
10 changed files with 1482 additions and 63 deletions

1185
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -18,6 +18,7 @@ pub fn run() {
env_logger::init();
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_dialog::init())
.plugin(tauri_plugin_opener::init())

View 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");
});
});

View File

@@ -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>

View 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");
});
});
});

View File

@@ -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)]"
@@ -274,7 +274,7 @@ export default function ProjectCard({ project }: Props) {
</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>
@@ -357,12 +357,13 @@ 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">
<div key={i} className="mb-1">
<div className="flex gap-1 items-center min-w-0">
<input
value={pp.host_path}
onChange={(e) => {
@@ -377,7 +378,7 @@ export default function ProjectCard({ project }: Props) {
}}
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"
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 () => {
@@ -393,11 +394,28 @@ export default function ProjectCard({ project }: Props) {
}
}}
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>
<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
value={pp.mount_name}
onChange={(e) => {
@@ -412,23 +430,9 @@ export default function ProjectCard({ project }: Props) {
}}
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"
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 && (
<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

View 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
View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

11
app/vitest.config.ts Normal file
View 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"],
},
});