Skip to content

Commit 2f8141c

Browse files
committed
Merge branch 'worktree-2025-12-19T05-47-35'
2 parents 1cd1ca2 + 35b9889 commit 2f8141c

File tree

14 files changed

+1021
-140
lines changed

14 files changed

+1021
-140
lines changed

packages/ui-vite/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"devDependencies": {
2727
"@eslint/js": "^9.39.1",
2828
"@tailwindcss/typography": "^0.5.15",
29+
"@tauri-apps/api": "^2.9.1",
2930
"@types/node": "^24.10.1",
3031
"@types/react": "^19.2.5",
3132
"@types/react-dom": "^19.2.3",

packages/ui-vite/src/components/Layout.tsx

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,50 @@
11
import { Outlet, Link, useLocation } from 'react-router-dom';
2-
import { BarChart3, FileText, Network, Settings } from 'lucide-react';
2+
import { BarChart3, FileText, Network, Settings, Keyboard } from 'lucide-react';
33
import { cn } from '../lib/utils';
4+
import { ProjectSwitcher } from './ProjectSwitcher';
5+
import { ThemeToggle } from './ThemeToggle';
6+
import { useGlobalShortcuts } from '../hooks/useKeyboardShortcuts';
7+
import { useState } from 'react';
8+
9+
function KeyboardShortcutsHelp({ onClose }: { onClose: () => void }) {
10+
const shortcuts = [
11+
{ key: 'g', description: 'Go to specs list' },
12+
{ key: 's', description: 'Go to stats' },
13+
{ key: 'd', description: 'Go to dependencies' },
14+
{ key: ',', description: 'Go to settings' },
15+
{ key: '/', description: 'Focus search' },
16+
{ key: '?', description: 'Show keyboard shortcuts' },
17+
];
18+
19+
return (
20+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
21+
<div className="bg-background border rounded-lg shadow-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
22+
<h3 className="text-lg font-medium mb-4">Keyboard Shortcuts</h3>
23+
<div className="space-y-2">
24+
{shortcuts.map((s) => (
25+
<div key={s.key} className="flex items-center justify-between">
26+
<span className="text-sm text-muted-foreground">{s.description}</span>
27+
<kbd className="px-2 py-1 text-xs bg-secondary rounded border">{s.key}</kbd>
28+
</div>
29+
))}
30+
</div>
31+
<button
32+
onClick={onClose}
33+
className="mt-4 w-full px-4 py-2 text-sm bg-secondary rounded-lg hover:bg-secondary/80 transition-colors"
34+
>
35+
Close
36+
</button>
37+
</div>
38+
</div>
39+
);
40+
}
441

542
export function Layout() {
643
const location = useLocation();
44+
const [showShortcuts, setShowShortcuts] = useState(false);
45+
46+
// Register global keyboard shortcuts
47+
useGlobalShortcuts();
748

849
const navItems = [
950
{ path: '/specs', label: 'Specs', icon: FileText },
@@ -18,6 +59,7 @@ export function Layout() {
1859
<div className="container mx-auto px-4 py-3 flex items-center justify-between">
1960
<div className="flex items-center gap-8">
2061
<h1 className="text-xl font-bold">LeanSpec</h1>
62+
<ProjectSwitcher />
2163
<nav className="flex gap-1">
2264
{navItems.map((item) => {
2365
const Icon = item.icon;
@@ -40,11 +82,22 @@ export function Layout() {
4082
})}
4183
</nav>
4284
</div>
85+
<div className="flex items-center gap-3">
86+
<button
87+
onClick={() => setShowShortcuts(true)}
88+
title="Keyboard shortcuts (?)"
89+
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-md transition-colors"
90+
>
91+
<Keyboard className="w-4 h-4" />
92+
</button>
93+
<ThemeToggle />
94+
</div>
4395
</div>
4496
</header>
4597
<main className="flex-1 container mx-auto px-4 py-6">
4698
<Outlet />
4799
</main>
100+
{showShortcuts && <KeyboardShortcutsHelp onClose={() => setShowShortcuts(false)} />}
48101
</div>
49102
);
50103
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useState, useRef, useEffect } from 'react';
2+
import { ChevronDown, Folder, Check } from 'lucide-react';
3+
import { useProject } from '../contexts';
4+
import { cn } from '../lib/utils';
5+
6+
export function ProjectSwitcher() {
7+
const { currentProject, availableProjects, loading, switchProject } = useProject();
8+
const [open, setOpen] = useState(false);
9+
const [switching, setSwitching] = useState(false);
10+
const dropdownRef = useRef<HTMLDivElement>(null);
11+
12+
// Close dropdown when clicking outside
13+
useEffect(() => {
14+
function handleClickOutside(event: MouseEvent) {
15+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
16+
setOpen(false);
17+
}
18+
}
19+
document.addEventListener('mousedown', handleClickOutside);
20+
return () => document.removeEventListener('mousedown', handleClickOutside);
21+
}, []);
22+
23+
const handleSwitch = async (projectId: string) => {
24+
if (projectId === currentProject?.id || switching) return;
25+
26+
setSwitching(true);
27+
try {
28+
await switchProject(projectId);
29+
setOpen(false);
30+
// Refresh the page to reload all data with new project
31+
window.location.reload();
32+
} catch {
33+
// Error is handled by context
34+
} finally {
35+
setSwitching(false);
36+
}
37+
};
38+
39+
if (loading && !currentProject) {
40+
return (
41+
<div className="flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground">
42+
<Folder className="w-4 h-4" />
43+
<span>Loading...</span>
44+
</div>
45+
);
46+
}
47+
48+
const allProjects = availableProjects;
49+
const hasMultipleProjects = allProjects.length > 1;
50+
51+
return (
52+
<div className="relative" ref={dropdownRef}>
53+
<button
54+
onClick={() => hasMultipleProjects && setOpen(!open)}
55+
disabled={!hasMultipleProjects || switching}
56+
className={cn(
57+
'flex items-center gap-2 px-3 py-1.5 text-sm rounded-md border transition-colors',
58+
hasMultipleProjects
59+
? 'hover:bg-secondary cursor-pointer'
60+
: 'cursor-default',
61+
switching && 'opacity-50'
62+
)}
63+
>
64+
<Folder className="w-4 h-4 text-primary" />
65+
<span className="max-w-[150px] truncate font-medium">
66+
{currentProject?.name || 'No project'}
67+
</span>
68+
{hasMultipleProjects && (
69+
<ChevronDown className={cn('w-4 h-4 transition-transform', open && 'rotate-180')} />
70+
)}
71+
</button>
72+
73+
{open && hasMultipleProjects && (
74+
<div className="absolute top-full left-0 mt-1 w-64 bg-background border rounded-md shadow-lg z-50">
75+
<div className="p-2 border-b">
76+
<span className="text-xs font-medium text-muted-foreground uppercase">
77+
Switch Project
78+
</span>
79+
</div>
80+
<div className="max-h-64 overflow-y-auto">
81+
{allProjects.map((project) => {
82+
const isCurrent = project.id === currentProject?.id;
83+
return (
84+
<button
85+
key={project.id}
86+
onClick={() => handleSwitch(project.id)}
87+
disabled={isCurrent || switching}
88+
className={cn(
89+
'w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-secondary transition-colors',
90+
isCurrent && 'bg-secondary/50'
91+
)}
92+
>
93+
<Folder className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
94+
<div className="flex-1 min-w-0">
95+
<div className="text-sm font-medium truncate">{project.name}</div>
96+
<div className="text-xs text-muted-foreground truncate">{project.path}</div>
97+
</div>
98+
{isCurrent && <Check className="w-4 h-4 text-primary flex-shrink-0" />}
99+
</button>
100+
);
101+
})}
102+
</div>
103+
</div>
104+
)}
105+
</div>
106+
);
107+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Sun, Moon, Monitor } from 'lucide-react';
2+
import { useTheme } from '../contexts';
3+
import { cn } from '../lib/utils';
4+
5+
export function ThemeToggle() {
6+
const { theme, setTheme } = useTheme();
7+
8+
const options = [
9+
{ value: 'light' as const, icon: Sun, label: 'Light' },
10+
{ value: 'dark' as const, icon: Moon, label: 'Dark' },
11+
{ value: 'system' as const, icon: Monitor, label: 'System' },
12+
];
13+
14+
return (
15+
<div className="flex items-center gap-1 p-1 bg-secondary rounded-lg">
16+
{options.map((option) => {
17+
const Icon = option.icon;
18+
const isActive = theme === option.value;
19+
return (
20+
<button
21+
key={option.value}
22+
onClick={() => setTheme(option.value)}
23+
title={option.label}
24+
className={cn(
25+
'p-1.5 rounded-md transition-colors',
26+
isActive
27+
? 'bg-background text-foreground shadow-sm'
28+
: 'text-muted-foreground hover:text-foreground'
29+
)}
30+
>
31+
<Icon className="w-4 h-4" />
32+
</button>
33+
);
34+
})}
35+
</div>
36+
);
37+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
2+
import { api, type Project, type ProjectsResponse } from '../lib/api';
3+
4+
interface ProjectContextValue {
5+
currentProject: Project | null;
6+
availableProjects: Project[];
7+
loading: boolean;
8+
error: string | null;
9+
switchProject: (projectId: string) => Promise<void>;
10+
refreshProjects: () => Promise<void>;
11+
}
12+
13+
const ProjectContext = createContext<ProjectContextValue | null>(null);
14+
15+
const STORAGE_KEY = 'leanspec-current-project';
16+
17+
export function ProjectProvider({ children }: { children: ReactNode }) {
18+
const [currentProject, setCurrentProject] = useState<Project | null>(null);
19+
const [availableProjects, setAvailableProjects] = useState<Project[]>([]);
20+
const [loading, setLoading] = useState(true);
21+
const [error, setError] = useState<string | null>(null);
22+
23+
const loadProjects = useCallback(async () => {
24+
setLoading(true);
25+
setError(null);
26+
try {
27+
const data: ProjectsResponse = await api.getProjects();
28+
setCurrentProject(data.current);
29+
setAvailableProjects(data.available);
30+
31+
// Persist current project to localStorage
32+
if (data.current) {
33+
localStorage.setItem(STORAGE_KEY, data.current.id);
34+
}
35+
} catch (err: any) {
36+
setError(err.message || 'Failed to load projects');
37+
} finally {
38+
setLoading(false);
39+
}
40+
}, []);
41+
42+
const switchProject = useCallback(async (projectId: string) => {
43+
if (projectId === currentProject?.id) return;
44+
45+
setLoading(true);
46+
setError(null);
47+
try {
48+
await api.switchProject(projectId);
49+
localStorage.setItem(STORAGE_KEY, projectId);
50+
// Reload projects to get updated current project
51+
await loadProjects();
52+
} catch (err: any) {
53+
setError(err.message || 'Failed to switch project');
54+
throw err;
55+
}
56+
}, [currentProject?.id, loadProjects]);
57+
58+
// Initial load
59+
useEffect(() => {
60+
loadProjects();
61+
}, [loadProjects]);
62+
63+
return (
64+
<ProjectContext.Provider
65+
value={{
66+
currentProject,
67+
availableProjects,
68+
loading,
69+
error,
70+
switchProject,
71+
refreshProjects: loadProjects,
72+
}}
73+
>
74+
{children}
75+
</ProjectContext.Provider>
76+
);
77+
}
78+
79+
export function useProject() {
80+
const context = useContext(ProjectContext);
81+
if (!context) {
82+
throw new Error('useProject must be used within a ProjectProvider');
83+
}
84+
return context;
85+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
2+
3+
type Theme = 'light' | 'dark' | 'system';
4+
5+
interface ThemeContextValue {
6+
theme: Theme;
7+
setTheme: (theme: Theme) => void;
8+
resolvedTheme: 'light' | 'dark';
9+
}
10+
11+
const ThemeContext = createContext<ThemeContextValue | null>(null);
12+
13+
const STORAGE_KEY = 'leanspec-theme';
14+
15+
function getSystemTheme(): 'light' | 'dark' {
16+
if (typeof window === 'undefined') return 'light';
17+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
18+
}
19+
20+
export function ThemeProvider({ children }: { children: ReactNode }) {
21+
const [theme, setThemeState] = useState<Theme>(() => {
22+
if (typeof window === 'undefined') return 'system';
23+
return (localStorage.getItem(STORAGE_KEY) as Theme) || 'system';
24+
});
25+
26+
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
27+
28+
// Apply theme to document
29+
useEffect(() => {
30+
const root = document.documentElement;
31+
root.classList.remove('light', 'dark');
32+
root.classList.add(resolvedTheme);
33+
}, [resolvedTheme]);
34+
35+
// Listen for system theme changes
36+
useEffect(() => {
37+
if (theme !== 'system') return;
38+
39+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
40+
const handleChange = () => {
41+
const root = document.documentElement;
42+
root.classList.remove('light', 'dark');
43+
root.classList.add(getSystemTheme());
44+
};
45+
46+
mediaQuery.addEventListener('change', handleChange);
47+
return () => mediaQuery.removeEventListener('change', handleChange);
48+
}, [theme]);
49+
50+
const setTheme = (newTheme: Theme) => {
51+
setThemeState(newTheme);
52+
localStorage.setItem(STORAGE_KEY, newTheme);
53+
};
54+
55+
return (
56+
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
57+
{children}
58+
</ThemeContext.Provider>
59+
);
60+
}
61+
62+
export function useTheme() {
63+
const context = useContext(ThemeContext);
64+
if (!context) {
65+
throw new Error('useTheme must be used within a ThemeProvider');
66+
}
67+
return context;
68+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ProjectProvider, useProject } from './ProjectContext';
2+
export { ThemeProvider, useTheme } from './ThemeContext';

0 commit comments

Comments
 (0)