Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { expect, test } from "bun:test";

test("project tab bar exposes accessible switchable project tabs", async () => {
test("project tab bar exposes accessible switchable and closable project tabs", async () => {
const source = await Bun.file(new URL("./project-tab-bar.tsx", import.meta.url)).text();

expect(source).toContain('role="tablist"');
expect(source).toContain('role="tab"');
expect(source).toContain("aria-selected");
expect(source).toContain("isSwitchingProject");
expect(source).toContain("switchToProject");
expect(source).toContain("closeProject");
expect(source).toContain("XIcon as X");
expect(source).toContain("isProjectActionPending");
expect(source).toContain("event.stopPropagation()");
expect(source).toContain("await closeProject(projectId)");
expect(source).toContain("finally");
expect(source).toContain('t("titleProject.closeProject", { name: project.name })');
expect(source).toContain("group-hover:opacity-100");
expect(source).toContain("group-focus-within:opacity-100");
});
114 changes: 77 additions & 37 deletions windows/tauri/src/features/window/components/project-tab-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useFileSystemStore } from "@/features/file-system/stores/file-system.store";
import { useTranslation } from "@/i18n/locale-provider";
import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store";
import { FolderIcon } from "@/ui/icons";
import { Button } from "@/ui/button";
import { FolderIcon, XIcon as X } from "@/ui/icons";
import { cn } from "@/utils/cn";
import { getProjectTabBarItems } from "../utils/project-tab-bar-model";

export function ProjectTabBar() {
const { t } = useTranslation();
const [closingProjectId, setClosingProjectId] = useState<string | null>(null);
const projectTabs = useWorkspaceTabsStore.use.projectTabs();
const switchToProject = useFileSystemStore((state) => state.switchToProject);
const closeProject = useFileSystemStore((state) => state.closeProject);
const isSwitchingProject = useFileSystemStore((state) => state.isSwitchingProject);
const projects = useMemo(() => getProjectTabBarItems(projectTabs), [projectTabs]);
const isProjectActionPending = isSwitchingProject || closingProjectId !== null;

const handleCloseProject = async (projectId: string) => {
if (isProjectActionPending) return;

setClosingProjectId(projectId);
try {
await closeProject(projectId);
} finally {
setClosingProjectId(null);
}
};

if (projects.length === 0) return null;

Expand All @@ -23,41 +38,66 @@ export function ProjectTabBar() {
aria-orientation="horizontal"
>
<div className="flex min-w-max items-center gap-1">
{projects.map((project) => (
<button
key={project.id}
type="button"
role="tab"
aria-selected={project.isActive}
disabled={isSwitchingProject}
title={project.path}
onClick={() => {
if (project.isActive) return;
void switchToProject(project.id);
}}
className={cn(
"group relative flex h-8 min-w-44 max-w-64 items-center gap-2 rounded-md border px-3 text-left ui-text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary/30",
project.isActive
? "border-primary/45 bg-selected text-foreground"
: "border-transparent text-subtle-foreground hover:border-border/70 hover:bg-accent hover:text-foreground",
)}
>
<FolderIcon
className={cn(
"size-3.5 shrink-0",
project.isActive ? "text-primary" : "text-subtle-foreground",
)}
aria-hidden="true"
/>
<span className="min-w-0 truncate">{project.name}</span>
{project.isActive ? (
<span
aria-hidden="true"
className="absolute inset-x-3 -bottom-px h-px bg-primary"
/>
) : null}
</button>
))}
{projects.map((project) => {
const closeLabel = t("titleProject.closeProject", { name: project.name });

return (
<div key={project.id} className="group relative">
<button
type="button"
role="tab"
aria-selected={project.isActive}
disabled={isProjectActionPending}
title={project.path}
onClick={() => {
if (project.isActive) return;
void switchToProject(project.id);
}}
className={cn(
"relative flex h-8 min-w-44 max-w-64 items-center gap-2 rounded-md border py-0 pr-9 pl-3 text-left ui-text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed",
project.isActive
? "border-primary/45 bg-selected text-foreground"
: "border-transparent text-subtle-foreground hover:border-border/70 hover:bg-accent hover:text-foreground",
)}
>
<FolderIcon
className={cn(
"size-3.5 shrink-0",
project.isActive ? "text-primary" : "text-subtle-foreground",
)}
aria-hidden="true"
/>
<span className="min-w-0 truncate">{project.name}</span>
{project.isActive ? (
<span
aria-hidden="true"
className="absolute inset-x-3 -bottom-px h-px bg-primary"
/>
) : null}
</button>
<Button
type="button"
size="icon-xs"
variant="ghost"
aria-label={closeLabel}
tooltip={closeLabel}
disabled={isProjectActionPending}
onClick={(event) => {
event.stopPropagation();
void handleCloseProject(project.id);
}}
className={cn(
"absolute top-1/2 right-1 z-10 -translate-y-1/2 transition-opacity",
project.isActive
? "opacity-100"
: "pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100",
)}
>
<X className="pointer-events-none select-none" aria-hidden="true" />
</Button>
</div>
);
})}
</div>
</div>
);
Expand Down
4 changes: 4 additions & 0 deletions windows/tauri/src/i18n/locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ describe("Windows display language", () => {
expect(translate("git.log.headCurrentBranch")).toBe("HEAD(当前分支)");
expect(translate("run.identifyAndGenerate")).toBe("识别并生成");
expect(createTranslator("en-US")("workbench.run")).toBe("Run");
expect(createTranslator("en-US")("titleProject.closeProject", { name: "Lithe" })).toBe(
"Close project Lithe",
);
expect(translate("titleProject.closeProject", { name: "Lithe" })).toBe("关闭项目 Lithe");
expect(translate("welcome.openProject")).toBe("打开项目");
expect(translate("workbench.diagnostics")).toBe("诊断");
expect(translate("diagnostics.empty")).toBe("未检测到问题");
Expand Down
2 changes: 2 additions & 0 deletions windows/tauri/src/i18n/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const catalogs = {
"titleProject.open": "Open…",
"titleProject.cloneRepository": "Clone Repository…",
"titleProject.openProjects": "Open Projects",
"titleProject.closeProject": "Close project {name}",
"titleProject.recentProjects": "Recent Projects",
"titleProject.noRecentProjects": "No recent projects",
"titleProject.trigger": "Project: {project}",
Expand Down Expand Up @@ -1179,6 +1180,7 @@ const catalogs = {
"titleProject.open": "打开…",
"titleProject.cloneRepository": "克隆仓库…",
"titleProject.openProjects": "打开的项目",
"titleProject.closeProject": "关闭项目 {name}",
"titleProject.recentProjects": "最近项目",
"titleProject.noRecentProjects": "没有最近项目",
"titleProject.trigger": "项目:{project}",
Expand Down
Loading