diff --git a/packages/core/modals/store.ts b/packages/core/modals/store.ts index f720be433..81031245d 100644 --- a/packages/core/modals/store.ts +++ b/packages/core/modals/store.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; -type ModalType = "create-workspace" | "create-issue" | null; +type ModalType = "create-workspace" | "create-issue" | "create-project" | null; interface ModalStore { modal: ModalType; diff --git a/packages/views/modals/create-project.tsx b/packages/views/modals/create-project.tsx new file mode 100644 index 000000000..2179455d5 --- /dev/null +++ b/packages/views/modals/create-project.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { useState, useRef } from "react"; +import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { useCreateProject } from "@multica/core/projects/mutations"; +import { + PROJECT_STATUS_CONFIG, + PROJECT_STATUS_ORDER, + PROJECT_PRIORITY_CONFIG, + PROJECT_PRIORITY_ORDER, +} from "@multica/core/projects/config"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; +import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; +import { useActorName } from "@multica/core/workspace/hooks"; +import type { ProjectStatus, ProjectPriority } from "@multica/core/types"; +import { cn } from "@multica/ui/lib/utils"; +import { toast } from "sonner"; +import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@multica/ui/components/ui/dropdown-menu"; +import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; +import { Button } from "@multica/ui/components/ui/button"; +import { EmojiPicker } from "@multica/ui/components/common/emoji-picker"; +import { ContentEditor, type ContentEditorRef, TitleEditor } from "../editor"; +import { PriorityIcon } from "../issues/components/priority-icon"; +import { ActorAvatar } from "../common/actor-avatar"; +import { useNavigation } from "../navigation"; + +function PillButton({ + children, + className, + ...props +}: React.ButtonHTMLAttributes) { + return ( + + ); +} + +export function CreateProjectModal({ onClose }: { onClose: () => void }) { + const router = useNavigation(); + const workspace = useCurrentWorkspace(); + const workspaceName = workspace?.name; + const wsPaths = useWorkspacePaths(); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const { getActorName } = useActorName(); + + const [title, setTitle] = useState(""); + const descEditorRef = useRef(null); + const [status, setStatus] = useState("planned"); + const [priority, setPriority] = useState("none"); + const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(); + const [leadId, setLeadId] = useState(); + const [icon, setIcon] = useState(); + const [iconPickerOpen, setIconPickerOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + const [leadOpen, setLeadOpen] = useState(false); + const [leadFilter, setLeadFilter] = useState(""); + + const leadQuery = leadFilter.toLowerCase(); + const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery)); + const filteredAgents = agents.filter( + (a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery), + ); + + const leadLabel = leadType && leadId ? getActorName(leadType, leadId) : "Lead"; + + const createProject = useCreateProject(); + + const handleSubmit = async () => { + if (!title.trim() || submitting) return; + setSubmitting(true); + try { + const project = await createProject.mutateAsync({ + title: title.trim(), + description: descEditorRef.current?.getMarkdown()?.trim() || undefined, + icon, + status, + priority, + lead_type: leadType, + lead_id: leadId, + }); + onClose(); + toast.success("Project created"); + router.push(wsPaths.projectDetail(project.id)); + } catch { + toast.error("Failed to create project"); + } finally { + setSubmitting(false); + } + }; + + return ( + { if (!v) onClose(); }}> + + New Project + +
+
+ {workspaceName} + + New project +
+
+ + setIsExpanded(!isExpanded)} + className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" + > + {isExpanded ? : } + + } + /> + {isExpanded ? "Collapse" : "Expand"} + + + + + + } + /> + Close + +
+
+ +
+ + + {icon || "📁"} + + } + /> + + { + setIcon(emoji); + setIconPickerOpen(false); + }} + /> + + + setTitle(v)} + onSubmit={handleSubmit} + /> +
+ +
+ +
+ +
+ + + + {PROJECT_STATUS_CONFIG[status].label} + + } + /> + + {PROJECT_STATUS_ORDER.map((s) => ( + setStatus(s)}> + + {PROJECT_STATUS_CONFIG[s].label} + + ))} + + + + + + + {PROJECT_PRIORITY_CONFIG[priority].label} + + } + /> + + {PROJECT_PRIORITY_ORDER.map((pr) => ( + setPriority(pr)}> + + {PROJECT_PRIORITY_CONFIG[pr].label} + + ))} + + + + { + setLeadOpen(v); + if (!v) setLeadFilter(""); + }} + > + + {leadType && leadId ? ( + <> + + {leadLabel} + + ) : ( + Lead + )} + + } + /> + +
+ setLeadFilter(e.target.value)} + placeholder="Assign lead..." + className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" + /> +
+
+ + {filteredMembers.length > 0 && ( + <> +
+ Members +
+ {filteredMembers.map((m) => ( + + ))} + + )} + {filteredAgents.length > 0 && ( + <> +
+ Agents +
+ {filteredAgents.map((a) => ( + + ))} + + )} + {filteredMembers.length === 0 && + filteredAgents.length === 0 && + leadFilter && ( +
+ No results +
+ )} +
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/packages/views/modals/registry.tsx b/packages/views/modals/registry.tsx index d97709bbc..6f0bd6a98 100644 --- a/packages/views/modals/registry.tsx +++ b/packages/views/modals/registry.tsx @@ -3,6 +3,7 @@ import { useModalStore } from "@multica/core/modals"; import { CreateWorkspaceModal } from "./create-workspace"; import { CreateIssueModal } from "./create-issue"; +import { CreateProjectModal } from "./create-project"; export function ModalRegistry() { const modal = useModalStore((s) => s.modal); @@ -14,6 +15,8 @@ export function ModalRegistry() { return ; case "create-issue": return ; + case "create-project": + return ; default: return null; } diff --git a/packages/views/projects/components/projects-page.tsx b/packages/views/projects/components/projects-page.tsx index 7725c880c..95bf2a868 100644 --- a/packages/views/projects/components/projects-page.tsx +++ b/packages/views/projects/components/projects-page.tsx @@ -1,26 +1,26 @@ "use client"; -import { useState, useRef, useCallback } from "react"; -import { Plus, FolderKanban, ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus, Check } from "lucide-react"; +import { useState, useCallback } from "react"; +import { Plus, FolderKanban, UserMinus, Check } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { projectListOptions } from "@multica/core/projects/queries"; -import { useCreateProject, useUpdateProject } from "@multica/core/projects/mutations"; -import { PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER, PROJECT_PRIORITY_CONFIG, PROJECT_PRIORITY_ORDER } from "@multica/core/projects/config"; +import { useUpdateProject } from "@multica/core/projects/mutations"; +import { + PROJECT_STATUS_CONFIG, + PROJECT_STATUS_ORDER, + PROJECT_PRIORITY_CONFIG, + PROJECT_PRIORITY_ORDER, +} from "@multica/core/projects/config"; import { useWorkspaceId } from "@multica/core/hooks"; -import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; +import { useWorkspacePaths } from "@multica/core/paths"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; -import { AppLink, useNavigation } from "../../navigation"; +import { useModalStore } from "@multica/core/modals"; +import { AppLink } from "../../navigation"; import { ActorAvatar } from "../../common/actor-avatar"; import { useActorName } from "@multica/core/workspace/hooks"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { Button } from "@multica/ui/components/ui/button"; import { cn } from "@multica/ui/lib/utils"; -import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogTitle, -} from "@multica/ui/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -33,9 +33,6 @@ import { PopoverContent, } from "@multica/ui/components/ui/popover"; import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; -import { ContentEditor, type ContentEditorRef } from "../../editor"; -import { TitleEditor } from "../../editor"; -import { EmojiPicker } from "@multica/ui/components/common/emoji-picker"; import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types"; import { PageHeader } from "../../layout/page-header"; import { PriorityIcon } from "../../issues/components/priority-icon"; @@ -229,316 +226,11 @@ function ProjectRow({ project }: { project: Project }) { ); } -function PillButton({ - children, - className, - ...props -}: React.ButtonHTMLAttributes) { - return ( - - ); -} - -function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { - const router = useNavigation(); - const workspace = useCurrentWorkspace(); - const workspaceName = workspace?.name; - const wsPaths = useWorkspacePaths(); - const wsId = useWorkspaceId(); - const { data: members = [] } = useQuery(memberListOptions(wsId)); - const { data: agents = [] } = useQuery(agentListOptions(wsId)); - const { getActorName } = useActorName(); - - const [title, setTitle] = useState(""); - const descEditorRef = useRef(null); - const [status, setStatus] = useState("planned"); - const [priority, setPriority] = useState("none"); - const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(); - const [leadId, setLeadId] = useState(); - const [icon, setIcon] = useState(); - const [iconPickerOpen, setIconPickerOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - - // Lead popover - const [leadOpen, setLeadOpen] = useState(false); - const [leadFilter, setLeadFilter] = useState(""); - - const leadQuery = leadFilter.toLowerCase(); - const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery)); - const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery)); - - const leadLabel = - leadType && leadId ? getActorName(leadType, leadId) : "Lead"; - - const createProject = useCreateProject(); - - const handleSubmit = async () => { - if (!title.trim() || submitting) return; - setSubmitting(true); - try { - const project = await createProject.mutateAsync({ - title: title.trim(), - description: descEditorRef.current?.getMarkdown()?.trim() || undefined, - icon, - status, - priority, - lead_type: leadType, - lead_id: leadId, - }); - onOpenChange(false); - setTitle(""); - setIcon(undefined); - setStatus("planned"); - setPriority("none"); - setLeadType(undefined); - setLeadId(undefined); - toast.success("Project created"); - router.push(wsPaths.projectDetail(project.id)); - } catch { - toast.error("Failed to create project"); - } finally { - setSubmitting(false); - } - }; - - return ( - - - New Project - - {/* Header */} -
-
- {workspaceName} - - New project -
-
- - setIsExpanded(!isExpanded)} - className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" - > - {isExpanded ? : } - - } - /> - {isExpanded ? "Collapse" : "Expand"} - - - onOpenChange(false)} - className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" - > - - - } - /> - Close - -
-
- - {/* Icon + Title */} -
- - - {icon || "📁"} - - } - /> - - { - setIcon(emoji); - setIconPickerOpen(false); - }} - /> - - - setTitle(v)} - onSubmit={handleSubmit} - /> -
- - {/* Description */} -
- -
- - {/* Property toolbar */} -
- {/* Status */} - - - - {PROJECT_STATUS_CONFIG[status].label} - - } - /> - - {PROJECT_STATUS_ORDER.map((s) => ( - setStatus(s)}> - - {PROJECT_STATUS_CONFIG[s].label} - - ))} - - - - {/* Priority */} - - - - {PROJECT_PRIORITY_CONFIG[priority].label} - - } - /> - - {PROJECT_PRIORITY_ORDER.map((p) => ( - setPriority(p)}> - - {PROJECT_PRIORITY_CONFIG[p].label} - - ))} - - - - {/* Lead */} - { setLeadOpen(v); if (!v) setLeadFilter(""); }}> - - {leadType && leadId ? ( - <> - - {leadLabel} - - ) : ( - Lead - )} - - } - /> - -
- setLeadFilter(e.target.value)} - placeholder="Assign lead..." - className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" - /> -
-
- - {filteredMembers.length > 0 && ( - <> -
Members
- {filteredMembers.map((m) => ( - - ))} - - )} - {filteredAgents.length > 0 && ( - <> -
Agents
- {filteredAgents.map((a) => ( - - ))} - - )} - {filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && ( -
No results
- )} -
-
-
-
- - {/* Footer */} -
- -
-
-
- ); -} export function ProjectsPage() { const wsId = useWorkspaceId(); const { data: projects = [], isLoading } = useQuery(projectListOptions(wsId)); - const [createOpen, setCreateOpen] = useState(false); + const openCreateProject = () => useModalStore.getState().open("create-project"); return (
@@ -551,7 +243,7 @@ export function ProjectsPage() { {projects.length} )}
- @@ -569,7 +261,7 @@ export function ProjectsPage() {

No projects yet

-
@@ -593,8 +285,6 @@ export function ProjectsPage() { )} - - ); } diff --git a/packages/views/search/search-command.test.tsx b/packages/views/search/search-command.test.tsx index 23fcb2105..6fdb1b65e 100644 --- a/packages/views/search/search-command.test.tsx +++ b/packages/views/search/search-command.test.tsx @@ -5,12 +5,40 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { SearchCommand } from "./search-command"; import { useSearchStore } from "./search-store"; -const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({ +const { + mockPush, + mockSearchIssues, + mockSearchProjects, + mockRecentItems, + mockAllIssues, + mockSetTheme, + mockTheme, + mockPathname, + mockGetShareableUrl, + mockWorkspaces, + mockCurrentWorkspace, + mockOpenModal, + mockToastSuccess, + mockClipboardWrite, +} = vi.hoisted(() => ({ mockPush: vi.fn(), mockSearchIssues: vi.fn(), mockSearchProjects: vi.fn(), mockRecentItems: { current: [] as Array<{ id: string; visitedAt: number }> }, mockAllIssues: { current: [] as Array> }, + mockSetTheme: vi.fn(), + mockTheme: { current: "system" as "light" | "dark" | "system" }, + mockPathname: { current: "/ws-test/issues" as string }, + mockGetShareableUrl: vi.fn((p: string) => `https://app.multica/${p}`), + mockWorkspaces: { + current: [] as Array<{ id: string; name: string; slug: string }>, + }, + mockCurrentWorkspace: { + current: null as { id: string; name: string; slug: string } | null, + }, + mockOpenModal: vi.fn(), + mockToastSuccess: vi.fn(), + mockClipboardWrite: vi.fn(() => Promise.resolve()), })); vi.mock("@multica/core/api", () => ({ @@ -32,6 +60,12 @@ vi.mock("@multica/core", () => ({ })); vi.mock("@multica/core/paths", () => ({ + paths: { + workspace: (slug: string) => ({ + issues: () => `/${slug}/issues`, + }), + }, + useCurrentWorkspace: () => mockCurrentWorkspace.current, useWorkspacePaths: () => ({ inbox: () => "/ws-test/inbox", myIssues: () => "/ws-test/my-issues", @@ -50,16 +84,40 @@ vi.mock("@multica/core/issues/queries", () => ({ issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }), })); +vi.mock("@multica/core/workspace/queries", () => ({ + workspaceListOptions: () => ({ queryKey: ["workspaces", "list"], enabled: false }), +})); + +vi.mock("@multica/core/modals", () => ({ + useModalStore: Object.assign(vi.fn(), { + getState: () => ({ open: mockOpenModal }), + }), +})); + vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: mockAllIssues.current }), + useQuery: (opts: { queryKey: readonly unknown[] }) => { + const key = opts.queryKey; + if (key[0] === "workspaces") return { data: mockWorkspaces.current }; + return { data: mockAllIssues.current }; + }, })); vi.mock("../navigation", () => ({ useNavigation: () => ({ push: mockPush, + pathname: mockPathname.current, + getShareableUrl: mockGetShareableUrl, }), })); +vi.mock("@multica/ui/components/common/theme-provider", () => ({ + useTheme: () => ({ theme: mockTheme.current, setTheme: mockSetTheme }), +})); + +vi.mock("sonner", () => ({ + toast: { success: mockToastSuccess, error: vi.fn() }, +})); + describe("SearchCommand", () => { beforeEach(() => { mockPush.mockReset(); @@ -67,6 +125,15 @@ describe("SearchCommand", () => { mockSearchProjects.mockReset().mockResolvedValue({ projects: [] }); mockRecentItems.current = []; mockAllIssues.current = []; + mockSetTheme.mockReset(); + mockTheme.current = "system"; + mockPathname.current = "/ws-test/issues"; + mockGetShareableUrl.mockReset().mockImplementation((p: string) => `https://app.multica/${p}`); + mockWorkspaces.current = []; + mockCurrentWorkspace.current = null; + mockOpenModal.mockReset(); + mockToastSuccess.mockReset(); + mockClipboardWrite.mockReset().mockResolvedValue(undefined); // cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement Element.prototype.scrollIntoView = vi.fn(); @@ -94,10 +161,19 @@ describe("SearchCommand", () => { expect(screen.queryByPlaceholderText("Type a command or search...")).not.toBeInTheDocument(); }); - it("does not show pages when no query is entered", () => { + it("shows Commands by default but hides Pages and Switch Workspace until query", () => { render(); expect(screen.queryByText("Pages")).not.toBeInTheDocument(); + expect(screen.queryByText("Switch Workspace")).not.toBeInTheDocument(); + // Commands surface by default for discoverability. + expect(screen.getByText("Commands")).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "New Project" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); }); it("filters navigation pages by query", async () => { @@ -112,7 +188,6 @@ describe("SearchCommand", () => { expect(screen.getByText((_, el) => el?.textContent === "Settings" && el?.tagName === "SPAN")).toBeInTheDocument(); }); expect(screen.queryByText("Inbox")).not.toBeInTheDocument(); - expect(screen.queryByText("Projects")).not.toBeInTheDocument(); }); it("navigates to page on selection", async () => { @@ -148,6 +223,198 @@ describe("SearchCommand", () => { expect(screen.getByText("MUL-2")).toBeInTheDocument(); }); + it("shows New Issue / New Project under Commands and triggers the modal store", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "new"); + + await waitFor(() => { + expect(screen.getByText("Commands")).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "New Project" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + }); + + const newIssue = await screen.findByText( + (_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN", + ); + await user.click(newIssue); + + expect(mockOpenModal).toHaveBeenCalledWith("create-issue"); + expect(useSearchStore.getState().open).toBe(false); + }); + + it("hides copy-link commands when not on an issue detail route", async () => { + const user = userEvent.setup(); + mockPathname.current = "/ws-test/projects"; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "copy"); + + // Commands section may still be empty / absent. + expect(screen.queryByText("Copy Issue Link")).not.toBeInTheDocument(); + }); + + it("copies issue link and identifier when on an issue detail route", async () => { + const user = userEvent.setup(); + // userEvent.setup() installs its own navigator.clipboard; spy on it so we + // intercept the writeText call without clobbering userEvent's internals. + const writeSpy = vi + .spyOn(navigator.clipboard, "writeText") + .mockImplementation(mockClipboardWrite); + mockPathname.current = "/ws-test/issues/issue-1"; + mockAllIssues.current = [ + { id: "issue-1", identifier: "MUL-42", title: "Demo", status: "todo" }, + ]; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "copy"); + + const linkItem = await screen.findByText( + (_, el) => el?.textContent === "Copy Issue Link" && el?.tagName === "SPAN", + ); + await user.click(linkItem); + + expect(mockGetShareableUrl).toHaveBeenCalledWith("/ws-test/issues/issue-1"); + expect(mockClipboardWrite).toHaveBeenCalledWith("https://app.multica//ws-test/issues/issue-1"); + expect(mockToastSuccess).toHaveBeenCalledWith("Link copied"); + + // Reopen palette and test identifier copy + act(() => { + useSearchStore.setState({ open: true }); + }); + const input2 = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input2, "copy"); + const idItem = await screen.findByText( + (_, el) => + el?.textContent === "Copy Identifier (MUL-42)" && el?.tagName === "SPAN", + ); + await user.click(idItem); + expect(mockClipboardWrite).toHaveBeenCalledWith("MUL-42"); + expect(mockToastSuccess).toHaveBeenCalledWith("Copied MUL-42"); + + writeSpy.mockRestore(); + }); + + it("filters theme commands by query keywords", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "dark"); + + await waitFor(() => { + expect(screen.getByText("Commands")).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + }); + expect(screen.queryByText("Switch to Light Theme")).not.toBeInTheDocument(); + expect(screen.queryByText("Use System Theme")).not.toBeInTheDocument(); + }); + + it("applies the selected theme and closes the palette", async () => { + const user = userEvent.setup(); + mockTheme.current = "light"; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "dark"); + + const darkItem = await screen.findByText( + (_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN", + ); + await user.click(darkItem); + + expect(mockSetTheme).toHaveBeenCalledWith("dark"); + expect(useSearchStore.getState().open).toBe(false); + }); + + it("matches theme action via generic 'theme' keyword and marks current theme", async () => { + const user = userEvent.setup(); + mockTheme.current = "dark"; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "theme"); + + await waitFor(() => { + expect( + screen.getByText((_, el) => el?.textContent === "Switch to Light Theme" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Use System Theme" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + }); + expect(screen.getByLabelText("Current theme")).toBeInTheDocument(); + }); + + it("lists other workspaces under Switch Workspace and navigates on select", async () => { + const user = userEvent.setup(); + mockCurrentWorkspace.current = { id: "ws-current", name: "Current", slug: "current" }; + mockWorkspaces.current = [ + { id: "ws-current", name: "Current", slug: "current" }, + { id: "ws-alpha", name: "Alpha Co", slug: "alpha" }, + { id: "ws-beta", name: "Beta Co", slug: "beta" }, + ]; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "alpha"); + + await waitFor(() => { + expect(screen.getByText("Switch Workspace")).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + }); + expect(screen.queryByText("Beta Co")).not.toBeInTheDocument(); + expect(screen.queryByText("Current")).not.toBeInTheDocument(); + + const alphaItem = await screen.findByText( + (_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN", + ); + await user.click(alphaItem); + + expect(mockPush).toHaveBeenCalledWith("/alpha/issues"); + expect(useSearchStore.getState().open).toBe(false); + }); + + it("shows all other workspaces when typing 'workspace'", async () => { + const user = userEvent.setup(); + mockCurrentWorkspace.current = { id: "ws-current", name: "Current", slug: "current" }; + mockWorkspaces.current = [ + { id: "ws-current", name: "Current", slug: "current" }, + { id: "ws-alpha", name: "Alpha Co", slug: "alpha" }, + { id: "ws-beta", name: "Beta Co", slug: "beta" }, + ]; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "workspace"); + + await waitFor(() => { + expect(screen.getByText("Switch Workspace")).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Beta Co" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + }); + expect(screen.queryByText("Current")).not.toBeInTheDocument(); + }); + it("filters out recent items not present in query cache", () => { mockRecentItems.current = [ { id: "issue-1", visitedAt: 1000 }, diff --git a/packages/views/search/search-command.tsx b/packages/views/search/search-command.tsx index 98d1db136..b064fdb4f 100644 --- a/packages/views/search/search-command.tsx +++ b/packages/views/search/search-command.tsx @@ -2,9 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + Check, Clock, + Copy, + Link2, Loader2, MessageSquare, + Plus, SearchIcon, Inbox, CircleUser, @@ -12,19 +16,25 @@ import { FolderKanban, Bot, Monitor, + Moon, + Sun, BookOpenText, Settings, + Building2, type LucideIcon, } from "lucide-react"; import { Command as CommandPrimitive } from "cmdk"; import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types"; import { api } from "@multica/core/api"; import { useRecentIssuesStore } from "@multica/core/issues/stores"; import { issueListOptions } from "@multica/core/issues/queries"; import { useWorkspaceId } from "@multica/core"; -import { useWorkspacePaths } from "@multica/core/paths"; +import { paths, useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; import type { WorkspacePaths } from "@multica/core/paths"; +import { useModalStore } from "@multica/core/modals"; +import { workspaceListOptions } from "@multica/core/workspace/queries"; import { StatusIcon } from "../issues/components"; import { STATUS_CONFIG } from "@multica/core/issues/config"; import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config"; @@ -36,6 +46,7 @@ import { DialogTitle, DialogDescription, } from "@multica/ui/components/ui/dialog"; +import { useTheme } from "@multica/ui/components/common/theme-provider"; import { useNavigation } from "../navigation"; import { useSearchStore } from "./search-store"; @@ -106,19 +117,33 @@ const navPages: NavPage[] = [ { key: "settings", label: "Settings", icon: Settings, keywords: ["settings", "config", "preferences"] }, ]; +type ThemeValue = "light" | "dark" | "system"; + +interface CommandItem { + key: string; + label: string; + icon: LucideIcon; + keywords: string[]; + trailing?: React.ReactNode; + onSelect: () => void; +} + interface SearchResults { issues: SearchIssueResult[]; projects: SearchProjectResult[]; } export function SearchCommand() { - const { push } = useNavigation(); + const { push, pathname, getShareableUrl } = useNavigation(); const open = useSearchStore((s) => s.open); const setOpen = useSearchStore((s) => s.setOpen); const recentItems = useRecentIssuesStore((s) => s.items); const wsId = useWorkspaceId(); const p: WorkspacePaths = useWorkspacePaths(); const { data: allIssues = [] } = useQuery(issueListOptions(wsId)); + const { theme, setTheme } = useTheme(); + const currentWorkspace = useCurrentWorkspace(); + const { data: workspaces = [] } = useQuery(workspaceListOptions()); const recentIssues = useMemo(() => { const issueMap = new Map(allIssues.map((i) => [i.id, i])); @@ -144,6 +169,144 @@ export function SearchCommand() { ); }, [query]); + // Detect if current route is an issue detail page — /{slug}/issues/{id}. + // Falls back to null on any other route; used to gate issue-specific commands. + const currentIssue = useMemo(() => { + const match = pathname.match(/\/issues\/([^/]+)$/); + const raw = match?.[1]; + if (!raw) return null; + const id = decodeURIComponent(raw); + return allIssues.find((i) => i.id === id) ?? null; + }, [pathname, allIssues]); + + const commands = useMemo(() => { + const activeThemeCheck = (value: ThemeValue) => + theme === value ? ( + + ) : undefined; + + const items: CommandItem[] = [ + { + key: "new-issue", + label: "New Issue", + icon: Plus, + keywords: ["new", "issue", "create", "add"], + onSelect: () => { + useModalStore.getState().open("create-issue"); + setOpen(false); + }, + }, + { + key: "new-project", + label: "New Project", + icon: Plus, + keywords: ["new", "project", "create", "add"], + onSelect: () => { + useModalStore.getState().open("create-project"); + setOpen(false); + }, + }, + ]; + + if (currentIssue) { + const identifier = currentIssue.identifier; + items.push( + { + key: "copy-issue-link", + label: "Copy Issue Link", + icon: Link2, + keywords: ["copy", "link", "share", "url", identifier.toLowerCase()], + onSelect: () => { + const url = getShareableUrl ? getShareableUrl(pathname) : window.location.href; + void navigator.clipboard.writeText(url); + toast.success("Link copied"); + setOpen(false); + }, + }, + { + key: "copy-issue-identifier", + label: `Copy Identifier (${identifier})`, + icon: Copy, + keywords: ["copy", "id", "identifier", identifier.toLowerCase()], + onSelect: () => { + void navigator.clipboard.writeText(identifier); + toast.success(`Copied ${identifier}`); + setOpen(false); + }, + }, + ); + } + + items.push( + { + key: "theme-light", + label: "Switch to Light Theme", + icon: Sun, + keywords: ["light", "theme", "appearance", "mode", "bright"], + trailing: activeThemeCheck("light"), + onSelect: () => { + setTheme("light"); + setOpen(false); + }, + }, + { + key: "theme-dark", + label: "Switch to Dark Theme", + icon: Moon, + keywords: ["dark", "theme", "appearance", "mode", "night"], + trailing: activeThemeCheck("dark"), + onSelect: () => { + setTheme("dark"); + setOpen(false); + }, + }, + { + key: "theme-system", + label: "Use System Theme", + icon: Monitor, + keywords: ["system", "theme", "appearance", "mode", "auto"], + trailing: activeThemeCheck("system"), + onSelect: () => { + setTheme("system"); + setOpen(false); + }, + }, + ); + + return items; + }, [currentIssue, getShareableUrl, pathname, setOpen, setTheme, theme]); + + const filteredCommands = useMemo(() => { + const q = query.trim().toLowerCase(); + // No query: surface the whole Commands list so users can discover what's + // available without having to guess keywords (Linear/Raycast pattern). + if (!q) return commands; + return commands.filter( + (c) => + c.label.toLowerCase().includes(q) || + c.keywords.some((kw) => kw.includes(q)), + ); + }, [commands, query]); + + // Only show workspaces different from the current one, and only after the + // user types >=2 chars — one char would match everything (e.g. "w"). + const filteredWorkspaces = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return []; + const others = workspaces.filter((w) => w.id !== currentWorkspace?.id); + const wantsAll = + q.length >= 2 && ("workspace".startsWith(q) || "switch".startsWith(q)); + return others.filter( + (w) => + wantsAll || + w.name.toLowerCase().includes(q) || + w.slug.toLowerCase().includes(q), + ); + }, [workspaces, currentWorkspace?.id, query]); + const hasResults = results.issues.length > 0 || results.projects.length > 0; // Global Cmd+K / Ctrl+K shortcut @@ -262,6 +425,14 @@ export function SearchCommand() { [push, setOpen, p], ); + const handleSwitchWorkspace = useCallback( + (slug: string) => { + push(paths.workspace(slug).issues()); + setOpen(false); + }, + [push, setOpen], + ); + return ( )} + {/* Commands section — New Issue / New Project / Copy link / Theme, only shown when query matches */} + {filteredCommands.length > 0 && ( + +
+ Commands +
+ {filteredCommands.map((cmd) => ( + + + + + + {cmd.trailing} + + ))} +
+ )} + + {/* Workspaces section — switch to a different workspace, only shown when query matches */} + {filteredWorkspaces.length > 0 && ( + +
+ Switch Workspace +
+ {filteredWorkspaces.map((ws) => ( + handleSwitchWorkspace(ws.slug)} + className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent" + > + + + + + + {ws.slug} + + + ))} +
+ )} + {isLoading && (
)} - {!isLoading && query.trim() && !hasResults && filteredPages.length === 0 && ( - - No results found. - - )} + {!isLoading && + query.trim() && + !hasResults && + filteredPages.length === 0 && + filteredCommands.length === 0 && + filteredWorkspaces.length === 0 && ( + + No results found. + + )} {!isLoading && results.projects.length > 0 && ( - Type to search issues and projects... - Press ⌘K to open this anytime +
+ Type to search issues and projects
)}