Skip to content

Commit 35ac531

Browse files
authored
Merge pull request #93 from codervisor/copilot/complete-spec-169-work
Phase 4: React Router SPA for desktop Rust/Tauri migration (spec 169)
2 parents 94e2691 + 0826d2c commit 35ac531

File tree

13 files changed

+2173
-9
lines changed

13 files changed

+2173
-9
lines changed

packages/desktop/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"clsx": "^2.1.1",
2626
"lucide-react": "^0.553.0",
2727
"react": "19.2.0",
28-
"react-dom": "19.2.0"
28+
"react-dom": "19.2.0",
29+
"react-router-dom": "^7.10.1"
2930
},
3031
"devDependencies": {
3132
"@tauri-apps/cli": "^2.0.0",

packages/desktop/src/Router.tsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* React Router configuration for desktop SPA
3+
*
4+
* This replaces the Next.js file-based routing with React Router
5+
* for the native Tauri desktop app (Phase 4 of spec 169).
6+
*/
7+
8+
import { createBrowserRouter, RouterProvider, Outlet, Navigate } from 'react-router-dom';
9+
import DesktopLayout from './components/DesktopLayout';
10+
import TitleBar from './components/TitleBar';
11+
import { SpecsPage } from './pages/SpecsPage';
12+
import { SpecDetailPage } from './pages/SpecDetailPage';
13+
import { StatsPage } from './pages/StatsPage';
14+
import { DependenciesPage } from './pages/DependenciesPage';
15+
import { useProjects } from './hooks/useProjects';
16+
import styles from './app.module.css';
17+
18+
/**
19+
* Root layout that provides the desktop shell and project context
20+
*/
21+
function RootLayout() {
22+
const {
23+
projects,
24+
activeProjectId,
25+
loading,
26+
error,
27+
switchProject,
28+
addProject,
29+
refreshProjects,
30+
} = useProjects();
31+
32+
if (loading) {
33+
return (
34+
<DesktopLayout header={<TitleBar projects={[]} activeProjectId={undefined} onProjectSelect={() => {}} onAddProject={() => {}} onRefresh={() => {}} onManageProjects={() => {}} isLoading={true} />}>
35+
<div className={styles.centerState}>Loading desktop environment…</div>
36+
</DesktopLayout>
37+
);
38+
}
39+
40+
if (error) {
41+
return (
42+
<DesktopLayout header={<TitleBar projects={[]} activeProjectId={undefined} onProjectSelect={() => {}} onAddProject={() => {}} onRefresh={() => {}} onManageProjects={() => {}} isLoading={false} />}>
43+
<div className={styles.errorState}>
44+
<div style={{ fontSize: '1.2em', fontWeight: 600 }}>Unable to load projects</div>
45+
<div>{error}</div>
46+
</div>
47+
</DesktopLayout>
48+
);
49+
}
50+
51+
if (!activeProjectId) {
52+
return (
53+
<DesktopLayout
54+
header={
55+
<TitleBar
56+
projects={projects}
57+
activeProjectId={undefined}
58+
onProjectSelect={switchProject}
59+
onAddProject={addProject}
60+
onRefresh={refreshProjects}
61+
onManageProjects={() => {}}
62+
isLoading={false}
63+
/>
64+
}
65+
>
66+
<div className={styles.centerState}>
67+
<p>No project selected.</p>
68+
<button onClick={addProject} style={{ marginTop: '1rem', padding: '0.5rem 1rem', cursor: 'pointer' }}>
69+
Open a project
70+
</button>
71+
</div>
72+
</DesktopLayout>
73+
);
74+
}
75+
76+
return (
77+
<DesktopLayout
78+
header={
79+
<TitleBar
80+
projects={projects}
81+
activeProjectId={activeProjectId}
82+
onProjectSelect={switchProject}
83+
onAddProject={addProject}
84+
onRefresh={refreshProjects}
85+
onManageProjects={() => {}}
86+
isLoading={false}
87+
/>
88+
}
89+
>
90+
<Outlet context={{ projectId: activeProjectId, projects, refreshProjects }} />
91+
</DesktopLayout>
92+
);
93+
}
94+
95+
/**
96+
* Router configuration
97+
*
98+
* Routes mirror the Next.js structure but use React Router:
99+
* - /specs → Spec list page
100+
* - /specs/:specId → Spec detail page
101+
* - /stats → Statistics page
102+
* - /dependencies → Dependencies graph page
103+
*/
104+
const router = createBrowserRouter([
105+
{
106+
path: '/',
107+
element: <RootLayout />,
108+
children: [
109+
{
110+
index: true,
111+
element: <Navigate to="/specs" replace />,
112+
},
113+
{
114+
path: 'specs',
115+
element: <SpecsPage />,
116+
},
117+
{
118+
path: 'specs/:specId',
119+
element: <SpecDetailPage />,
120+
},
121+
{
122+
path: 'stats',
123+
element: <StatsPage />,
124+
},
125+
{
126+
path: 'dependencies',
127+
element: <DependenciesPage />,
128+
},
129+
],
130+
},
131+
]);
132+
133+
/**
134+
* App Router component
135+
*
136+
* This is the entry point for the SPA routing.
137+
* Use this instead of the iframe-based App component for native mode.
138+
*/
139+
export function AppRouter() {
140+
return <RouterProvider router={router} />;
141+
}
142+
143+
export default AppRouter;
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Dependencies page - Native Tauri implementation
3+
*
4+
* Displays the dependency graph visualization using
5+
* native Tauri commands. (Phase 4 of spec 169)
6+
*/
7+
8+
import { useMemo } from 'react';
9+
import { Link, useOutletContext } from 'react-router-dom';
10+
import { useDependencyGraph } from '../hooks/useSpecs';
11+
import styles from './dependencies-page.module.css';
12+
13+
interface DepsContext {
14+
projectId: string;
15+
}
16+
17+
export function DependenciesPage() {
18+
const { projectId } = useOutletContext<DepsContext>();
19+
const { graph, loading, error, refresh } = useDependencyGraph({ projectId });
20+
21+
const nodesByStatus = useMemo(() => {
22+
if (!graph) return {};
23+
24+
const grouped: Record<string, typeof graph.nodes> = {};
25+
graph.nodes.forEach(node => {
26+
const status = node.status || 'unknown';
27+
if (!grouped[status]) grouped[status] = [];
28+
grouped[status].push(node);
29+
});
30+
return grouped;
31+
}, [graph]);
32+
33+
if (loading) {
34+
return <div className={styles.loading}>Loading dependency graph…</div>;
35+
}
36+
37+
if (error) {
38+
return (
39+
<div className={styles.error}>
40+
<p>Failed to load dependency graph: {error}</p>
41+
<button onClick={refresh}>Retry</button>
42+
</div>
43+
);
44+
}
45+
46+
if (!graph) {
47+
return <div className={styles.loading}>No dependency data available</div>;
48+
}
49+
50+
return (
51+
<div className={styles.container}>
52+
<header className={styles.header}>
53+
<h1 className={styles.title}>Dependencies</h1>
54+
<p className={styles.subtitle}>
55+
{graph.nodes.length} specs with {graph.edges.length} dependencies
56+
</p>
57+
</header>
58+
59+
<main className={styles.content}>
60+
{/* Summary Stats */}
61+
<section className={styles.summary}>
62+
<div className={styles.statCard}>
63+
<div className={styles.statValue}>{graph.nodes.length}</div>
64+
<div className={styles.statLabel}>Specs</div>
65+
</div>
66+
<div className={styles.statCard}>
67+
<div className={styles.statValue}>{graph.edges.length}</div>
68+
<div className={styles.statLabel}>Dependencies</div>
69+
</div>
70+
<div className={styles.statCard}>
71+
<div className={styles.statValue}>
72+
{graph.nodes.filter(n =>
73+
graph.edges.some(e => e.source === n.id || e.target === n.id)
74+
).length}
75+
</div>
76+
<div className={styles.statLabel}>Connected Specs</div>
77+
</div>
78+
</section>
79+
80+
{/* Dependency List by Status */}
81+
<section className={styles.section}>
82+
<h2 className={styles.sectionTitle}>Specs by Status</h2>
83+
84+
{Object.entries(nodesByStatus).map(([status, nodes]) => (
85+
<div key={status} className={styles.statusGroup}>
86+
<h3 className={`${styles.statusHeader} ${styles[`status-${status}`]}`}>
87+
{formatStatus(status)} ({nodes.length})
88+
</h3>
89+
<div className={styles.nodeList}>
90+
{nodes.map(node => {
91+
const dependsOn = graph.edges.filter(e => e.source === node.id);
92+
const requiredBy = graph.edges.filter(e => e.target === node.id);
93+
94+
return (
95+
<div key={node.id} className={styles.nodeCard}>
96+
<Link to={`/specs/${node.name}`} className={styles.nodeTitle}>
97+
#{node.number.toString().padStart(3, '0')} {node.name}
98+
</Link>
99+
100+
<div className={styles.nodeMeta}>
101+
{node.priority && (
102+
<span className={`${styles.priority} ${styles[`priority-${node.priority}`]}`}>
103+
{node.priority}
104+
</span>
105+
)}
106+
{node.tags.length > 0 && (
107+
<span className={styles.tagCount}>{node.tags.length} tags</span>
108+
)}
109+
</div>
110+
111+
<div className={styles.connections}>
112+
{dependsOn.length > 0 && (
113+
<div className={styles.connectionGroup}>
114+
<span className={styles.connectionLabel}>Depends on:</span>
115+
<span className={styles.connectionList}>
116+
{dependsOn.map(e => {
117+
const target = graph.nodes.find(n => n.id === e.target);
118+
return target ? (
119+
<Link key={e.target} to={`/specs/${target.name}`} className={styles.connectionLink}>
120+
#{target.number}
121+
</Link>
122+
) : null;
123+
})}
124+
</span>
125+
</div>
126+
)}
127+
{requiredBy.length > 0 && (
128+
<div className={styles.connectionGroup}>
129+
<span className={styles.connectionLabel}>Required by:</span>
130+
<span className={styles.connectionList}>
131+
{requiredBy.map(e => {
132+
const source = graph.nodes.find(n => n.id === e.source);
133+
return source ? (
134+
<Link key={e.source} to={`/specs/${source.name}`} className={styles.connectionLink}>
135+
#{source.number}
136+
</Link>
137+
) : null;
138+
})}
139+
</span>
140+
</div>
141+
)}
142+
{dependsOn.length === 0 && requiredBy.length === 0 && (
143+
<span className={styles.noConnections}>No dependencies</span>
144+
)}
145+
</div>
146+
</div>
147+
);
148+
})}
149+
</div>
150+
</div>
151+
))}
152+
</section>
153+
</main>
154+
</div>
155+
);
156+
}
157+
158+
function formatStatus(status: string): string {
159+
const labels: Record<string, string> = {
160+
'planned': 'Planned',
161+
'in-progress': 'In Progress',
162+
'complete': 'Complete',
163+
'archived': 'Archived',
164+
'unknown': 'Unknown',
165+
};
166+
return labels[status] || status;
167+
}
168+
169+
export default DependenciesPage;

0 commit comments

Comments
 (0)