Skip to content

Commit 75dc8af

Browse files
committed
feat: support vibe coding in workspace
1 parent 179129b commit 75dc8af

12 files changed

Lines changed: 537 additions & 329 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import { useState, useRef, useEffect } from "react";
2+
import { useAtom } from "jotai";
3+
import { useSortable } from "@dnd-kit/sortable";
4+
import { CSS } from "@dnd-kit/utilities";
5+
import {
6+
Cross2Icon,
7+
CheckCircledIcon,
8+
UpdateIcon,
9+
ExclamationTriangleIcon,
10+
TimerIcon,
11+
DrawingPinFilledIcon,
12+
Pencil1Icon,
13+
ArchiveIcon,
14+
TrashIcon,
15+
} from "@radix-ui/react-icons";
16+
import {
17+
ContextMenu,
18+
ContextMenuContent,
19+
ContextMenuItem,
20+
ContextMenuTrigger,
21+
ContextMenuSub,
22+
ContextMenuSubTrigger,
23+
ContextMenuSubContent,
24+
ContextMenuSeparator,
25+
} from "@/components/ui/context-menu";
26+
import { workspaceDataAtom } from "@/store";
27+
import { invoke } from "@tauri-apps/api/core";
28+
import type { Feature, FeatureStatus, WorkspaceData } from "@/views/Workspace/types";
29+
30+
interface FeatureTabProps {
31+
feature: Feature;
32+
projectId: string;
33+
isActive: boolean;
34+
onSelect: () => void;
35+
isDragging?: boolean;
36+
}
37+
38+
function StatusIcon({ status }: { status: FeatureStatus }) {
39+
switch (status) {
40+
case "pending":
41+
return <TimerIcon className="w-3 h-3 text-muted-foreground" />;
42+
case "running":
43+
return <UpdateIcon className="w-3 h-3 text-blue-500" />;
44+
case "completed":
45+
return <CheckCircledIcon className="w-3 h-3 text-green-500" />;
46+
case "needs-review":
47+
return <ExclamationTriangleIcon className="w-3 h-3 text-amber-500" />;
48+
}
49+
}
50+
51+
export function FeatureTab({
52+
feature,
53+
projectId,
54+
isActive,
55+
onSelect,
56+
isDragging,
57+
}: FeatureTabProps) {
58+
const [workspace, setWorkspace] = useAtom(workspaceDataAtom);
59+
const [isRenaming, setIsRenaming] = useState(false);
60+
const [renameValue, setRenameValue] = useState(feature.name);
61+
const inputRef = useRef<HTMLInputElement>(null);
62+
const isComposingRef = useRef(false);
63+
64+
useEffect(() => {
65+
if (isRenaming) {
66+
requestAnimationFrame(() => {
67+
inputRef.current?.focus();
68+
inputRef.current?.select();
69+
});
70+
}
71+
}, [isRenaming]);
72+
73+
const saveWorkspace = async (data: WorkspaceData) => {
74+
setWorkspace(data);
75+
await invoke("workspace_save", { data });
76+
};
77+
78+
const handleRename = async () => {
79+
const trimmed = renameValue.trim();
80+
if (!trimmed || trimmed === feature.name || !workspace) {
81+
setIsRenaming(false);
82+
setRenameValue(feature.name);
83+
return;
84+
}
85+
86+
await invoke("workspace_rename_feature", { featureId: feature.id, name: trimmed });
87+
88+
const newProjects = workspace.projects.map((p) =>
89+
p.id === projectId
90+
? {
91+
...p,
92+
features: p.features.map((f) =>
93+
f.id === feature.id ? { ...f, name: trimmed } : f
94+
),
95+
}
96+
: p
97+
);
98+
99+
await saveWorkspace({ ...workspace, projects: newProjects });
100+
setIsRenaming(false);
101+
};
102+
103+
const handleArchive = async () => {
104+
if (!workspace) return;
105+
106+
const newProjects = workspace.projects.map((p) => {
107+
if (p.id !== projectId) return p;
108+
const activeFeatures = p.features.filter((f) => f.id !== feature.id && !f.archived);
109+
return {
110+
...p,
111+
features: p.features.map((f) =>
112+
f.id === feature.id ? { ...f, archived: true } : f
113+
),
114+
active_feature_id:
115+
p.active_feature_id === feature.id
116+
? activeFeatures[0]?.id
117+
: p.active_feature_id,
118+
};
119+
});
120+
121+
await saveWorkspace({ ...workspace, projects: newProjects });
122+
};
123+
124+
const handleDelete = async () => {
125+
if (!workspace) return;
126+
127+
const newProjects = workspace.projects.map((p) => {
128+
if (p.id !== projectId) return p;
129+
const remainingFeatures = p.features.filter((f) => f.id !== feature.id);
130+
const activeFeatures = remainingFeatures.filter((f) => !f.archived);
131+
return {
132+
...p,
133+
features: remainingFeatures,
134+
active_feature_id:
135+
p.active_feature_id === feature.id
136+
? activeFeatures[0]?.id
137+
: p.active_feature_id,
138+
};
139+
});
140+
141+
await saveWorkspace({ ...workspace, projects: newProjects });
142+
};
143+
144+
const handlePin = async () => {
145+
if (!workspace) return;
146+
147+
const newProjects = workspace.projects.map((p) =>
148+
p.id === projectId
149+
? {
150+
...p,
151+
features: p.features.map((f) =>
152+
f.id === feature.id ? { ...f, pinned: !f.pinned } : f
153+
),
154+
}
155+
: p
156+
);
157+
158+
await saveWorkspace({ ...workspace, projects: newProjects });
159+
};
160+
161+
const handleStatusChange = async (status: FeatureStatus) => {
162+
if (!workspace) return;
163+
164+
const newProjects = workspace.projects.map((p) =>
165+
p.id === projectId
166+
? {
167+
...p,
168+
features: p.features.map((f) =>
169+
f.id === feature.id ? { ...f, status } : f
170+
),
171+
}
172+
: p
173+
);
174+
175+
await saveWorkspace({ ...workspace, projects: newProjects });
176+
};
177+
178+
const handleDoubleClick = (e: React.MouseEvent) => {
179+
e.stopPropagation();
180+
setRenameValue(feature.name);
181+
setIsRenaming(true);
182+
};
183+
184+
const handleKeyDown = (e: React.KeyboardEvent) => {
185+
if (e.keyCode === 229) return; // IME
186+
if (e.key === "Enter" && !isComposingRef.current) {
187+
handleRename();
188+
} else if (e.key === "Escape") {
189+
setIsRenaming(false);
190+
setRenameValue(feature.name);
191+
}
192+
};
193+
194+
return (
195+
<ContextMenu>
196+
<ContextMenuTrigger asChild>
197+
<div
198+
onClick={onSelect}
199+
onDoubleClick={handleDoubleClick}
200+
onPointerDown={(e) => e.stopPropagation()}
201+
className={`group flex items-center gap-1 px-2 py-1 rounded-md cursor-pointer transition-colors ${
202+
isDragging
203+
? "bg-primary/20 shadow-lg"
204+
: isActive
205+
? "bg-primary/10 text-primary"
206+
: "text-muted-foreground hover:text-ink hover:bg-card-alt"
207+
}`}
208+
>
209+
{feature.pinned && (
210+
<DrawingPinFilledIcon className="w-2.5 h-2.5 text-primary/70 flex-shrink-0" />
211+
)}
212+
<StatusIcon status={feature.status} />
213+
{feature.seq > 0 && (
214+
<span className="text-[10px] text-muted-foreground/60">#{feature.seq}</span>
215+
)}
216+
{isRenaming ? (
217+
<input
218+
ref={inputRef}
219+
value={renameValue}
220+
onChange={(e) => setRenameValue(e.target.value)}
221+
onBlur={handleRename}
222+
onKeyDown={handleKeyDown}
223+
onCompositionStart={() => { isComposingRef.current = true; }}
224+
onCompositionEnd={() => { isComposingRef.current = false; }}
225+
onClick={(e) => e.stopPropagation()}
226+
className="w-16 text-xs bg-card border border-border rounded px-1 outline-none focus:border-primary"
227+
/>
228+
) : (
229+
<span className="text-xs truncate max-w-[60px]" title={feature.name}>
230+
{feature.name}
231+
</span>
232+
)}
233+
{/* Close button - archive on click */}
234+
<button
235+
onClick={(e) => {
236+
e.stopPropagation();
237+
handleArchive();
238+
}}
239+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-muted transition-all"
240+
title="Archive"
241+
>
242+
<Cross2Icon className="w-3 h-3" />
243+
</button>
244+
</div>
245+
</ContextMenuTrigger>
246+
247+
<ContextMenuContent className="min-w-[140px]">
248+
<ContextMenuItem
249+
onClick={() => {
250+
setRenameValue(feature.name);
251+
setIsRenaming(true);
252+
}}
253+
className="gap-2 cursor-pointer"
254+
>
255+
<Pencil1Icon className="w-3.5 h-3.5" />
256+
Rename
257+
</ContextMenuItem>
258+
<ContextMenuItem onClick={handlePin} className="gap-2 cursor-pointer">
259+
<DrawingPinFilledIcon className="w-3.5 h-3.5" />
260+
{feature.pinned ? "Unpin" : "Pin"}
261+
</ContextMenuItem>
262+
<ContextMenuSub>
263+
<ContextMenuSubTrigger className="gap-2">
264+
<StatusIcon status={feature.status} />
265+
Status
266+
</ContextMenuSubTrigger>
267+
<ContextMenuSubContent className="min-w-[120px]">
268+
<ContextMenuItem
269+
onClick={() => handleStatusChange("pending")}
270+
disabled={feature.status === "pending"}
271+
className="gap-2 cursor-pointer"
272+
>
273+
<TimerIcon className="w-3.5 h-3.5 text-muted-foreground" />
274+
Pending
275+
</ContextMenuItem>
276+
<ContextMenuItem
277+
onClick={() => handleStatusChange("running")}
278+
disabled={feature.status === "running"}
279+
className="gap-2 cursor-pointer"
280+
>
281+
<UpdateIcon className="w-3.5 h-3.5 text-blue-500" />
282+
Running
283+
</ContextMenuItem>
284+
<ContextMenuItem
285+
onClick={() => handleStatusChange("completed")}
286+
disabled={feature.status === "completed"}
287+
className="gap-2 cursor-pointer"
288+
>
289+
<CheckCircledIcon className="w-3.5 h-3.5 text-green-500" />
290+
Completed
291+
</ContextMenuItem>
292+
<ContextMenuItem
293+
onClick={() => handleStatusChange("needs-review")}
294+
disabled={feature.status === "needs-review"}
295+
className="gap-2 cursor-pointer"
296+
>
297+
<ExclamationTriangleIcon className="w-3.5 h-3.5 text-amber-500" />
298+
Needs Review
299+
</ContextMenuItem>
300+
</ContextMenuSubContent>
301+
</ContextMenuSub>
302+
<ContextMenuSeparator />
303+
<ContextMenuItem onClick={handleArchive} className="gap-2 cursor-pointer">
304+
<ArchiveIcon className="w-3.5 h-3.5" />
305+
Archive
306+
</ContextMenuItem>
307+
<ContextMenuItem
308+
onClick={handleDelete}
309+
className="gap-2 cursor-pointer text-destructive focus:text-destructive"
310+
>
311+
<TrashIcon className="w-3.5 h-3.5" />
312+
Delete
313+
</ContextMenuItem>
314+
</ContextMenuContent>
315+
</ContextMenu>
316+
);
317+
}
318+
319+
// Sortable wrapper for drag-and-drop
320+
export function SortableFeatureTab(props: Omit<FeatureTabProps, "isDragging">) {
321+
const {
322+
attributes,
323+
listeners,
324+
setNodeRef,
325+
transform,
326+
transition,
327+
isDragging,
328+
} = useSortable({ id: props.feature.id });
329+
330+
const style = {
331+
transform: CSS.Transform.toString(transform),
332+
transition,
333+
};
334+
335+
return (
336+
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
337+
<FeatureTab {...props} isDragging={isDragging} />
338+
</div>
339+
);
340+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { GlobalHeader } from "./GlobalHeader";
2+
export { GlobalFeatureTabs } from "./GlobalFeatureTabs";
3+
export { FeatureTabGroup } from "./FeatureTabGroup";
4+
export { FeatureTab, SortableFeatureTab } from "./FeatureTab";

src/store/atoms/workspace.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1+
import { atom } from "jotai";
12
import { atomWithStorage } from "jotai/utils";
3+
import type { WorkspaceData } from "@/views/Workspace/types";
4+
5+
// Primary feature for main nav (not affected by secondary nav like settings/marketplace from profile menu)
6+
export const primaryFeatureAtom = atomWithStorage<string | null>("lovcode:primaryFeature", null);
7+
8+
// Workspace data (shared between App.tsx and WorkspaceView)
9+
export const workspaceDataAtom = atom<WorkspaceData | null>(null);
10+
export const workspaceLoadingAtom = atom<boolean>(true);
11+
export const collapsedProjectGroupsAtom = atomWithStorage<string[]>("collapsed-project-groups", []);
212

313
// FeatureSidebar
414
export const featureSidebarExpandedPanelsAtom = atomWithStorage<string[]>("feature-sidebar-expanded-panels", []);

src/store/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export {
3030

3131
// Workspace atoms
3232
export {
33+
primaryFeatureAtom,
34+
workspaceDataAtom, workspaceLoadingAtom, collapsedProjectGroupsAtom,
3335
featureSidebarExpandedPanelsAtom, featureSidebarPinnedExpandedAtom, featureSidebarFilesExpandedAtom,
3436
} from "./atoms/workspace";
3537

0 commit comments

Comments
 (0)