From 700974bc4f344a770850c4e0d10e780d79f8118c Mon Sep 17 00:00:00 2001 From: Tiago Miguel Date: Wed, 23 Sep 2026 21:00:17 +0100 Subject: [PATCH 1/2] feat(workspace): update to 1.1.0 --- extensions/workspace/README.md | 10 +- extensions/workspace/package-lock.json | 4 +- extensions/workspace/package.json | 4 +- .../src/components/AddWorkspaceForm.tsx | 46 +- .../src/components/Git/CheckoutBranch.tsx | 11 +- .../src/components/Git/CommitLog.tsx | 4 +- .../src/components/ImportSettingsForm.tsx | 4 +- .../workspace/src/components/Onboarding.tsx | 40 +- .../workspace/src/components/ProjectItem.tsx | 299 +++++++++-- .../workspace/src/components/ReadmeView.tsx | 80 +++ .../workspace/src/components/SelectEditor.tsx | 2 +- .../workspace/src/components/Settings.tsx | 70 ++- .../components/Settings/DiscoverySettings.tsx | 223 ++++++++ .../components/Settings/GeneralSettings.tsx | 5 +- .../src/components/Settings/GitSettings.tsx | 55 +- .../Settings/ManagedWorkspacesSection.tsx | 9 +- .../Settings/RecentProjectsSettings.tsx | 2 + .../src/components/Settings/StatsSettings.tsx | 311 +++++++++++ .../src/components/Settings/TagsSettings.tsx | 487 ++++++++++++++++++ .../workspace/src/hooks/useCachedState.ts | 22 + .../workspace/src/hooks/usePreferences.ts | 39 ++ .../src/hooks/useProjectDiscovery.ts | 118 +++-- .../workspace/src/hooks/useRecentProjects.ts | 12 + extensions/workspace/src/hooks/useTags.ts | 130 +++++ .../workspace/src/hooks/useUsageStats.ts | 53 ++ .../workspace/src/hooks/useWorkspace.tsx | 89 +++- extensions/workspace/src/settings.tsx | 2 +- extensions/workspace/src/types/index.ts | 38 +- extensions/workspace/src/utils/constants.ts | 8 + extensions/workspace/src/utils/discovery.ts | 208 ++++++++ extensions/workspace/src/utils/git.ts | 133 ++++- .../workspace/src/utils/projectDisplay.ts | 125 +++++ extensions/workspace/src/utils/readme.ts | 31 ++ extensions/workspace/src/utils/storage.ts | 49 +- extensions/workspace/src/utils/suggestions.ts | 14 + extensions/workspace/src/utils/tags.ts | 157 ++++++ extensions/workspace/src/utils/usage.ts | 212 ++++++++ extensions/workspace/src/utils/version.ts | 7 + extensions/workspace/src/workspace.tsx | 85 ++- 39 files changed, 3047 insertions(+), 151 deletions(-) create mode 100644 extensions/workspace/src/components/ReadmeView.tsx create mode 100644 extensions/workspace/src/components/Settings/DiscoverySettings.tsx create mode 100644 extensions/workspace/src/components/Settings/StatsSettings.tsx create mode 100644 extensions/workspace/src/components/Settings/TagsSettings.tsx create mode 100644 extensions/workspace/src/hooks/useTags.ts create mode 100644 extensions/workspace/src/hooks/useUsageStats.ts create mode 100644 extensions/workspace/src/utils/discovery.ts create mode 100644 extensions/workspace/src/utils/projectDisplay.ts create mode 100644 extensions/workspace/src/utils/readme.ts create mode 100644 extensions/workspace/src/utils/suggestions.ts create mode 100644 extensions/workspace/src/utils/tags.ts create mode 100644 extensions/workspace/src/utils/usage.ts create mode 100644 extensions/workspace/src/utils/version.ts diff --git a/extensions/workspace/README.md b/extensions/workspace/README.md index dadf70302..0fa54f82e 100644 --- a/extensions/workspace/README.md +++ b/extensions/workspace/README.md @@ -8,7 +8,7 @@ Search, pin, and open projects from every folder you work in. -Workspace is a [Vicinae](https://vicinae.com) extension. Add workspace folders and it lists every project inside, with git status, per-workspace opening app, and a JSON backup of your setup. +Workspace is a [Vicinae](https://vicinae.com) extension. Add workspace folders and it lists every project inside, with git status, a custom app per workspace, and a JSON backup of your setup. ## Screenshots @@ -29,13 +29,13 @@ Workspace is a [Vicinae](https://vicinae.com) extension. Add workspace folders a 2. Set a default app (and a terminal, if you want) in **Workspace Settings**. 3. Open **Workspace**, search, and hit enter. -Pin the `Workspace` command in Vicinae if you want the fastest loop. +Pin the `Workspace` command in Vicinae to reach it faster. ## Commands -- **Workspace** – Search and open projects -- **Manage Workspaces** – Add, remove, and reorder workspace folders -- **Workspace Settings** – Apps, git status, backup, and reset +- **Workspace**: Search and open projects +- **Manage Workspaces**: Add, remove, and reorder workspace folders +- **Workspace Settings**: Apps, git status, backup, and reset ## Installation diff --git a/extensions/workspace/package-lock.json b/extensions/workspace/package-lock.json index d40881008..5f2e900a4 100644 --- a/extensions/workspace/package-lock.json +++ b/extensions/workspace/package-lock.json @@ -1,12 +1,12 @@ { "name": "workspace", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workspace", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "@vicinae/api": "^0.25.0" diff --git a/extensions/workspace/package.json b/extensions/workspace/package.json index c0617f87f..475826339 100644 --- a/extensions/workspace/package.json +++ b/extensions/workspace/package.json @@ -2,10 +2,10 @@ "$schema": "https://raw.githubusercontent.com/vicinaehq/vicinae/refs/heads/main/extra/schemas/extension.json", "name": "workspace", "title": "Workspace", - "description": "Manage and access your workspace projects. Boost your focus with quick project opening, git status tracking, and customizable application overrides.", + "description": "Open your workspace projects fast, with git status and a custom app per workspace.", "icon": "extension-icon.png", "author": "tiagem", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "platforms": [ "Linux", diff --git a/extensions/workspace/src/components/AddWorkspaceForm.tsx b/extensions/workspace/src/components/AddWorkspaceForm.tsx index e9535c8ed..b0c7aad01 100644 --- a/extensions/workspace/src/components/AddWorkspaceForm.tsx +++ b/extensions/workspace/src/components/AddWorkspaceForm.tsx @@ -1,9 +1,10 @@ -import { Action, ActionPanel, Form, popToRoot, showToast, Toast, useNavigation } from "@vicinae/api"; +import { Action, ActionPanel, Form, Icon, popToRoot, showToast, Toast, useNavigation } from "@vicinae/api"; import path from "path"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useWorkspace } from "@/hooks/useWorkspace"; import { isExistingDirectory, pathFromFormValue } from "@/utils/paths"; +import { suggestedWorkspacePaths } from "@/utils/suggestions"; interface AddWorkspaceFormProps { onDone?: () => Promise | void; @@ -13,14 +14,9 @@ export default function AddWorkspaceForm({ onDone }: AddWorkspaceFormProps) { const { pop } = useNavigation(); const { updateWorkspaces, workspaces } = useWorkspace(); const [error, setError] = useState(); + const suggestions = useMemo(() => suggestedWorkspacePaths(workspaces), [workspaces]); - async function handleSubmit(values: Form.Values) { - const workspacePath = pathFromFormValue(values.workspace); - if (!workspacePath) { - setError("Required"); - return; - } - + async function addWorkspace(workspacePath: string) { if (!isExistingDirectory(workspacePath)) { setError("Folder not found"); await showToast({ @@ -58,21 +54,49 @@ export default function AddWorkspaceForm({ onDone }: AddWorkspaceFormProps) { } } + async function handleSubmit(values: Form.Values) { + const workspacePath = pathFromFormValue(values.workspace); + if (!workspacePath) { + setError("Required"); + return; + } + + await addWorkspace(workspacePath); + } + return (
- + + {suggestions.length > 0 ? ( + + {suggestions.map((suggestion) => ( + addWorkspace(suggestion)} + title={`Add ${path.basename(suggestion)}`} + /> + ))} + + ) : null} } navigationTitle="Add Workspace" > + {suggestions.length > 0 ? ( + path.basename(suggestion)).join(", ")}. Use the action panel to add one quickly.`} + title="Suggestions" + /> + ) : null} )} + {!error && !isLoading && (branches?.length ?? 0) === 0 && ( + + )} {(branches ?? []).map((branch) => { const isCurrent = branch === currentBranch; return ( @@ -73,7 +76,7 @@ export default function CheckoutBranch({ onBranchChanged, project }: CheckoutBra {!isCurrent && ( handleCheckout(branch)} title="Checkout Branch" /> )} - + } icon={Icon.Shuffle} diff --git a/extensions/workspace/src/components/Git/CommitLog.tsx b/extensions/workspace/src/components/Git/CommitLog.tsx index d17f7386e..4a4334688 100644 --- a/extensions/workspace/src/components/Git/CommitLog.tsx +++ b/extensions/workspace/src/components/Git/CommitLog.tsx @@ -51,13 +51,15 @@ export default function CommitLog({ project }: CommitLogProps) { {data?.remoteUrl && ( )} - + diff --git a/extensions/workspace/src/components/ImportSettingsForm.tsx b/extensions/workspace/src/components/ImportSettingsForm.tsx index 184bb91e0..6105cf43a 100644 --- a/extensions/workspace/src/components/ImportSettingsForm.tsx +++ b/extensions/workspace/src/components/ImportSettingsForm.tsx @@ -1,4 +1,4 @@ -import { Action, ActionPanel, Form, showToast, Toast, useNavigation } from "@vicinae/api"; +import { Action, ActionPanel, Form, Icon, showToast, Toast, useNavigation } from "@vicinae/api"; import { useState } from "react"; import { isExistingFile, pathFromFormValue } from "@/utils/paths"; @@ -39,7 +39,7 @@ export default function ImportSettingsForm({ onImport }: ImportSettingsFormProps actions={ - + } diff --git a/extensions/workspace/src/components/Onboarding.tsx b/extensions/workspace/src/components/Onboarding.tsx index 8f8e084a6..f163a5e70 100644 --- a/extensions/workspace/src/components/Onboarding.tsx +++ b/extensions/workspace/src/components/Onboarding.tsx @@ -1,10 +1,14 @@ -import { Action, ActionPanel, Color, Icon, List, type Application } from "@vicinae/api"; +import { Action, ActionPanel, Color, Icon, List, showToast, Toast, type Application } from "@vicinae/api"; +import path from "path"; +import { useMemo } from "react"; import AddWorkspaceForm from "@/components/AddWorkspaceForm"; import ImportSettingsForm from "@/components/ImportSettingsForm"; import SelectEditor from "@/components/SelectEditor"; import Settings from "@/components/Settings"; +import { useWorkspace } from "@/hooks/useWorkspace"; import { App } from "@/types"; +import { suggestedWorkspacePaths } from "@/utils/suggestions"; interface OnboardingProps { defaultApp: App | null; @@ -23,10 +27,26 @@ export default function Onboarding({ onSelectDefaultApp, workspaces, }: OnboardingProps) { + const { updateWorkspaces } = useWorkspace(); const hasWorkspaces = workspaces.length > 0; const hasApp = !!defaultApp; const isReady = hasWorkspaces; const nextStep = !hasWorkspaces ? "Add your first workspace" : "Finish onboarding"; + const suggestions = useMemo(() => suggestedWorkspacePaths(workspaces), [workspaces]); + + const addSuggested = async (workspacePath: string) => { + if (workspaces.includes(workspacePath)) { + return; + } + + await updateWorkspaces([...workspaces, workspacePath]); + await loadData(); + await showToast({ + message: path.basename(workspacePath), + style: Toast.Style.Success, + title: "Workspace Added", + }); + }; return ( @@ -99,6 +119,24 @@ export default function Onboarding({ /> )} + {suggestions.length > 0 ? ( + + {suggestions.map((suggestion) => ( + + addSuggested(suggestion)} title="Add Workspace" /> + + + } + icon={Icon.Folder} + subtitle={suggestion} + title={path.basename(suggestion)} + /> + ))} + + ) : null} void; onRefresh: () => Promise; + onRefreshGit?: (fullPath: string) => Promise; onReorderPin: (fullPath: string, direction: "down" | "up") => Promise; onTogglePin: (fullPath: string) => Promise; project: Project; showGitStatus: boolean; + showStashCount?: boolean; terminalApp: App | null; workspaceApps: Record; workspacePath: string; @@ -33,22 +52,93 @@ export default function ProjectItem({ isPinned, onOpen, onRefresh, + onRefreshGit, onReorderPin, onTogglePin, project, showGitStatus, + showStashCount = false, terminalApp, workspaceApps, workspacePath, }: ProjectItemProps) { + const { assignTag, createTag, getProjectTags, tags, unassignTag } = useTags(); + const projectTags = getProjectTags(project.fullPath); const workspaceApp = workspaceApps[workspacePath]; const appToUse = workspaceApp || defaultApp; + const [remoteBusy, setRemoteBusy] = useState(false); + const assignedIds = useMemo(() => new Set(projectTags.map((tag) => tag.id)), [projectTags]); const accessories = useMemo(() => { - if (!project.gitStatus || !showGitStatus) return []; + const tagAccessories = projectTags.slice(0, 2).map((tag) => ({ + tag: { color: tag.color as Color, value: tag.name }, + tooltip: `Tag: ${tag.name}`, + })); + const gitAccessories = + project.gitStatus && showGitStatus ? getGitAccessories(project.gitStatus, showStashCount) : []; + return [...tagAccessories, ...gitAccessories]; + }, [project.gitStatus, projectTags, showGitStatus, showStashCount]); + + const openRemote = async () => { + setRemoteBusy(true); + try { + const url = await getRemoteUrl(project.fullPath); + if (!url) { + await showToast({ style: Toast.Style.Failure, title: "No remote origin URL" }); + return; + } + await open(url); + } finally { + setRemoteBusy(false); + } + }; + + const openPullRequests = async () => { + setRemoteBusy(true); + try { + const url = await getRemoteUrl(project.fullPath); + if (!url) { + await showToast({ style: Toast.Style.Failure, title: "No remote origin URL" }); + return; + } + await open(pullsBrowserUrl(url)); + } finally { + setRemoteBusy(false); + } + }; + + const copyCloneUrl = async () => { + setRemoteBusy(true); + try { + const url = await getCloneUrl(project.fullPath); + if (!url) { + await showToast({ style: Toast.Style.Failure, title: "No remote origin URL" }); + return; + } + await Clipboard.copy(url); + await showToast({ message: url, style: Toast.Style.Success, title: "Clone URL copied" }); + } finally { + setRemoteBusy(false); + } + }; - return getGitAccessories(project.gitStatus); - }, [project.gitStatus, showGitStatus]); + const refreshGit = async () => { + const toast = await showToast({ style: Toast.Style.Animated, title: "Refreshing git status…" }); + try { + if (onRefreshGit) { + await onRefreshGit(project.fullPath); + } else { + await onRefresh(); + } + toast.style = Toast.Style.Success; + toast.title = "Git status updated"; + } catch { + toast.style = Toast.Style.Failure; + toast.title = "Couldn't refresh git status"; + } + }; + + const availableTags = tags.filter((tag) => !assignedIds.has(tag.id)); const actions = ( @@ -78,6 +168,19 @@ export default function ProjectItem({ shortcut={{ key: "enter", modifiers: ["cmd", "shift"] }} title="Open in Terminal" /> + + } + title="Open README" + /> onTogglePin(project.fullPath)} @@ -89,7 +192,7 @@ export default function ProjectItem({ icon={Icon.ArrowUp} onAction={() => onReorderPin(project.fullPath, "up")} shortcut={SHORTCUT_MOVE_UP} - title="Move Up" + title="Move Pin Up" /> )} {canMovePinDown && ( @@ -97,72 +200,127 @@ export default function ProjectItem({ icon={Icon.ArrowDown} onAction={() => onReorderPin(project.fullPath, "down")} shortcut={SHORTCUT_MOVE_DOWN} - title="Move Down" + title="Move Pin Down" /> )} + + {availableTags.map((tag) => ( + { + await assignTag(project.fullPath, tag.id); + await showToast({ style: Toast.Style.Success, title: `Tagged ${tag.name}` }); + }} + title={`Add Tag: ${tag.name}`} + /> + ))} + {projectTags.map((tag) => ( + { + await unassignTag(project.fullPath, tag.id); + await showToast({ style: Toast.Style.Success, title: `Removed ${tag.name}` }); + }} + title={`Remove Tag: ${tag.name}`} + /> + ))} + } + title="Create & Assign Tag…" + /> + {(project.isGitRepo || project.gitStatus) && ( } - title="Checkout Branch" + target={ + onRefreshGit(project.fullPath) : onRefresh} + project={project} + /> + } + title="Checkout Branch…" /> { - const toast = await showToast({ style: Toast.Style.Animated, title: "Pulling changes..." }); - const success = await pullGitBranch(project.fullPath); - if (success) { + const toast = await showToast({ style: Toast.Style.Animated, title: "Pulling changes…" }); + const result = await pullGitBranch(project.fullPath); + if (result.ok) { toast.style = Toast.Style.Success; toast.title = "Pulled successfully"; - await onRefresh(); + if (onRefreshGit) { + await onRefreshGit(project.fullPath); + } else { + await onRefresh(); + } } else { toast.style = Toast.Style.Failure; - toast.title = "Failed to pull changes"; - toast.message = "Check for uncommitted changes or conflicts."; + toast.title = "Pull failed"; + toast.message = result.message; } }} shortcut={{ key: "u", modifiers: ["cmd", "shift"] }} title="Pull Changes" /> } - title="View Commit Log" + title="View Commit Log…" + /> + + + + )} - + - + - + } - title="Workspace Settings" + title="Open Workspace Settings…" /> @@ -172,26 +330,90 @@ export default function ProjectItem({ ); } -function getGitAccessories(gitStatus: GitStatus) { +function CreateAndAssignTagForm({ projectPath }: { projectPath: string }) { + const { assignTag, createTag } = useTags(); + const { pop } = useNavigation(); + const [error, setError] = useState(); + + async function handleSubmit(values: { color?: string; name?: string }) { + const name = values.name?.trim() ?? ""; + if (!name) { + setError("Required"); + return; + } + + const color = (TAG_COLORS.find((entry) => entry.color === values.color)?.color ?? Color.Blue) as Color; + const tag = await createTag(name, color); + if (!tag) { + setError("Could not create tag"); + return; + } + + await assignTag(projectPath, tag.id); + await showToast({ style: Toast.Style.Success, title: `Tagged ${tag.name}` }); + pop(); + } + + return ( + + + + } + navigationTitle="Create Tag" + > + setError(undefined)} + placeholder="work, personal, client…" + title="Name" + /> + + {TAG_COLORS.map((entry) => ( + + ))} + + + ); +} + +function getGitAccessories(gitStatus: GitStatus, showStashCount: boolean) { const accessories: { tag: { color: Color; value: string }; tooltip: string }[] = []; + const modified = gitStatus.modified ?? gitStatus.dirty ?? 0; + const untracked = gitStatus.untracked ?? 0; + const dirty = gitStatus.dirty ?? modified + untracked; + const stash = gitStatus.stash ?? 0; - if (gitStatus.dirty) { + if (dirty > 0) { + if (modified > 0) { + accessories.push({ + tag: { color: Color.Orange, value: String(modified) }, + tooltip: updatedTooltip(`${modified} modified file${modified === 1 ? "" : "s"}`, gitStatus.updatedAt), + }); + } + if (untracked > 0) { + accessories.push({ + tag: { color: Color.SecondaryText, value: `?${untracked}` }, + tooltip: updatedTooltip(`${untracked} untracked file${untracked === 1 ? "" : "s"}`, gitStatus.updatedAt), + }); + } + } + + if (showStashCount && stash > 0) { accessories.push({ - tag: { color: Color.SecondaryText, value: String(gitStatus.dirty) }, - tooltip: `${gitStatus.dirty} uncommitted change${gitStatus.dirty > 1 ? "s" : ""}`, + tag: { color: Color.Blue, value: `stash ${stash}` }, + tooltip: `${stash} stash entr${stash === 1 ? "y" : "ies"}`, }); } @@ -211,8 +433,13 @@ function getGitAccessories(gitStatus: GitStatus) { accessories.push({ tag: { color: branchColor, value: parts.join(" ") }, - tooltip: `Branch: ${gitStatus.branch}`, + tooltip: updatedTooltip(`Branch: ${gitStatus.branch}`, gitStatus.updatedAt), }); return accessories; } + +function updatedTooltip(base: string, updatedAt?: number): string { + const age = formatGitUpdatedAt(updatedAt); + return age ? `${base} · updated ${age}` : base; +} diff --git a/extensions/workspace/src/components/ReadmeView.tsx b/extensions/workspace/src/components/ReadmeView.tsx new file mode 100644 index 000000000..2e990ca56 --- /dev/null +++ b/extensions/workspace/src/components/ReadmeView.tsx @@ -0,0 +1,80 @@ +import { Action, ActionPanel, Detail, Icon, List, open, showToast, Toast, useNavigation } from "@vicinae/api"; +import { readFile } from "fs/promises"; +import path from "path"; +import { useEffect } from "react"; + +import { useCachedPromise } from "@/hooks/useCachedPromise"; +import { findReadmePath } from "@/utils/readme"; + +interface ReadmeViewProps { + appId?: string; + appName?: string; + projectName: string; + projectPath: string; +} + +async function loadReadme(projectPath: string): Promise<{ contents: string; fileName: string; fullPath: string } | null> { + const readmePath = await findReadmePath(projectPath); + if (!readmePath) { + return null; + } + + const contents = await readFile(readmePath, "utf-8"); + return { contents, fileName: path.basename(readmePath), fullPath: readmePath }; +} + +export default function ReadmeView({ appId, appName, projectName, projectPath }: ReadmeViewProps) { + const { pop } = useNavigation(); + const { data, error, isLoading, revalidate } = useCachedPromise(loadReadme, [projectPath]); + + useEffect(() => { + if (!error || isLoading || data) { + return; + } + + void showToast({ message: error.message, style: Toast.Style.Failure, title: "Couldn't load README" }); + }, [data, error, isLoading]); + + if (!isLoading && (error || !data)) { + return ( + + + + + + } + description={error ? error.message : "This project has no README file."} + title={error ? "Couldn't Load README" : "No README Found"} + /> + + ); + } + + return ( + + open(data.fullPath, appId)} + title={appName ? `Open in ${appName}` : "Open in Default App"} + /> + + + ) : undefined + } + markdown={data?.contents ?? "Loading README…"} + metadata={ + + + + + + } + navigationTitle={`README · ${projectName}`} + /> + ); +} diff --git a/extensions/workspace/src/components/SelectEditor.tsx b/extensions/workspace/src/components/SelectEditor.tsx index 99d58c139..d23739839 100644 --- a/extensions/workspace/src/components/SelectEditor.tsx +++ b/extensions/workspace/src/components/SelectEditor.tsx @@ -64,7 +64,7 @@ export default function SelectEditor({ onReset, onSelect }: SelectEditorProps) { actions={ - + } diff --git a/extensions/workspace/src/components/Settings.tsx b/extensions/workspace/src/components/Settings.tsx index 9c608a8e1..57b56a4b0 100644 --- a/extensions/workspace/src/components/Settings.tsx +++ b/extensions/workspace/src/components/Settings.tsx @@ -1,9 +1,12 @@ import { Action, ActionPanel, Icon, List } from "@vicinae/api"; +import DiscoverySettings from "@/components/Settings/DiscoverySettings"; import GeneralSettings from "@/components/Settings/GeneralSettings"; import GitSettings from "@/components/Settings/GitSettings"; import ManagedWorkspacesSection from "@/components/Settings/ManagedWorkspacesSection"; import RecentProjectsSettings from "@/components/Settings/RecentProjectsSettings"; +import StatsSettings from "@/components/Settings/StatsSettings"; +import TagsSettings from "@/components/Settings/TagsSettings"; import { useWorkspace } from "@/hooks/useWorkspace"; interface SettingsProps { @@ -13,23 +16,38 @@ interface SettingsProps { export default function Settings({ onWorkspacesChanged, showGeneral = true }: SettingsProps) { const { + clearUsageStats, defaultApp, exportSettings, gitAvailable, + ignorePatterns, importSettings, + includeNested, loadData, + projectTags, + projects, recentProjectsCount, + requireMarkers, resetExtension, + scanDepth, showGitStatus, showRecentProjects, + showStashCount, + tags, terminalApp, updateDefaultApp, + updateIgnorePatterns, + updateIncludeNested, updateRecentProjectsCount, + updateRequireMarkers, + updateScanDepth, updateShowGitStatus, updateShowRecentProjects, + updateShowStashCount, updateTerminalApp, updateWorkspaceApps, updateWorkspaces, + usageSnapshot, workspaceApps, workspaces, } = useWorkspace(); @@ -52,10 +70,11 @@ export default function Settings({ onWorkspacesChanged, showGeneral = true }: Se const workspaceCount = workspaces.length; const workspaceSummary = workspaceCount === 0 ? "None added" : `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}`; + const gitRepoCount = projects.filter((project) => project.isGitRepo || project.gitStatus != null).length; return ( - + - - + + + + + + + + + + ); } diff --git a/extensions/workspace/src/components/Settings/DiscoverySettings.tsx b/extensions/workspace/src/components/Settings/DiscoverySettings.tsx new file mode 100644 index 000000000..529cb9b86 --- /dev/null +++ b/extensions/workspace/src/components/Settings/DiscoverySettings.tsx @@ -0,0 +1,223 @@ +import { Action, ActionPanel, Form, Icon, List, showToast, Toast, useNavigation } from "@vicinae/api"; +import { useState } from "react"; + +import { DEFAULT_IGNORE_PATTERNS, DEFAULT_SCAN_DEPTH } from "@/utils/discovery"; + +const SCAN_DEPTH_OPTIONS = [1, 2, 3, 5] as const; + +interface DiscoverySettingsProps { + ignorePatterns: string[]; + includeNested: boolean; + onChanged?: () => Promise; + requireMarkers: boolean; + scanDepth: number; + updateIgnorePatterns: (patterns: string[]) => Promise; + updateIncludeNested: (value: boolean) => Promise; + updateRequireMarkers: (value: boolean) => Promise; + updateScanDepth: (depth: number) => Promise; +} + +export default function DiscoverySettings({ + ignorePatterns, + includeNested, + onChanged, + requireMarkers, + scanDepth, + updateIgnorePatterns, + updateIncludeNested, + updateRequireMarkers, + updateScanDepth, +}: DiscoverySettingsProps) { + const setDepth = async (depth: number) => { + await updateScanDepth(depth); + if (onChanged) { + await onChanged(); + } + await showToast({ + message: depthLabel(depth), + style: Toast.Style.Success, + title: "Scan depth updated", + }); + }; + + const toggleIncludeNested = async () => { + const next = !includeNested; + await updateIncludeNested(next); + if (onChanged) { + await onChanged(); + } + await showToast({ + style: Toast.Style.Success, + title: next ? "Nested projects enabled" : "Nested projects disabled", + }); + }; + + const toggleRequireMarkers = async () => { + const next = !requireMarkers; + await updateRequireMarkers(next); + if (onChanged) { + await onChanged(); + } + await showToast({ + style: Toast.Style.Success, + title: next ? "Only marker projects" : "All folders at depth 1", + }); + }; + + const resetIgnorePatterns = async () => { + await updateIgnorePatterns(DEFAULT_IGNORE_PATTERNS); + if (onChanged) { + await onChanged(); + } + await showToast({ style: Toast.Style.Success, title: "Ignore patterns reset" }); + }; + + return ( + + + {SCAN_DEPTH_OPTIONS.map((depth) => ( + setDepth(depth)} + title={depthLabel(depth)} + /> + ))} + + + + + + + + } + title="Edit Ignore Patterns" + /> + + + + } + detail={ + + + 1 ? "Required for nested scans" : "Optional at depth 1"} + /> + + + + + } + /> + } + icon={Icon.MagnifyingGlass} + id="discovery" + keywords={["scan", "depth", "ignore", "monorepo", "nested", "markers"]} + title="Discovery" + /> + ); +} + +function EditIgnorePatternsForm({ + ignorePatterns, + onChanged, + updateIgnorePatterns, +}: { + ignorePatterns: string[]; + onChanged?: () => Promise; + updateIgnorePatterns: (patterns: string[]) => Promise; +}) { + const { pop } = useNavigation(); + const [error, setError] = useState(); + + async function handleSubmit(values: { patterns?: string }) { + const raw = values.patterns ?? ""; + const next = raw + .split(/[\n,]/) + .map((value) => value.trim()) + .filter(Boolean); + + if (next.length === 0) { + setError("Add at least one pattern, or reset to defaults from Discovery settings"); + return; + } + + await updateIgnorePatterns(next); + if (onChanged) { + await onChanged(); + } + await showToast({ style: Toast.Style.Success, title: "Ignore patterns updated" }); + pop(); + } + + return ( +
+ + + } + navigationTitle="Ignore Patterns" + > + + setError(undefined)} + placeholder={DEFAULT_IGNORE_PATTERNS.join("\n")} + title="Patterns" + /> + + ); +} + +function depthLabel(depth: number): string { + if (depth <= DEFAULT_SCAN_DEPTH) { + return "Top level only"; + } + if (depth >= 5) { + return "5 levels"; + } + return `${depth} levels`; +} + +function discoveryMarkdown(scanDepth: number, requireMarkers: boolean, includeNested: boolean): string { + const lines = [ + "Control how Workspace finds projects inside each workspace folder.", + "", + `- **Depth ${scanDepth}:** ${scanDepth === 1 ? "only immediate child folders" : `search up to ${scanDepth} levels deep`}.`, + `- **Markers:** folders with \`.git\`, \`package.json\`, \`Cargo.toml\`, and similar count as projects${scanDepth > 1 ? " (required when depth > 1)" : ""}.`, + `- **Require markers:** ${requireMarkers ? "on, also at depth 1" : "off, depth 1 lists every folder"}.`, + `- **Nested:** ${includeNested ? "keep scanning inside project folders" : "stop when a project root is found"}.`, + `- **Custom icon:** put an image at \`.workspace/icon.png\` (also \`.jpg\` / \`.webp\` / \`.svg\` / \`.ico\`).`, + ]; + return lines.join("\n"); +} diff --git a/extensions/workspace/src/components/Settings/GeneralSettings.tsx b/extensions/workspace/src/components/Settings/GeneralSettings.tsx index 4a91adf80..e7af9ac7c 100644 --- a/extensions/workspace/src/components/Settings/GeneralSettings.tsx +++ b/extensions/workspace/src/components/Settings/GeneralSettings.tsx @@ -4,6 +4,7 @@ import ImportSettingsForm from "@/components/ImportSettingsForm"; import SelectEditor from "@/components/SelectEditor"; import { App } from "@/types"; import { toApp } from "@/utils/validation"; +import { EXTENSION_VERSION } from "@/utils/version"; interface GeneralSettingsProps { defaultApp: App | null; @@ -102,13 +103,15 @@ export default function GeneralSettings({ + + } /> } icon={Icon.AppWindow} id="general" - keywords={["app", "terminal", "backup", "export", "import", "reset"]} + keywords={["app", "terminal", "backup", "export", "import", "reset", "version"]} title="General" /> ); diff --git a/extensions/workspace/src/components/Settings/GitSettings.tsx b/extensions/workspace/src/components/Settings/GitSettings.tsx index 010e77adf..757388884 100644 --- a/extensions/workspace/src/components/Settings/GitSettings.tsx +++ b/extensions/workspace/src/components/Settings/GitSettings.tsx @@ -4,14 +4,18 @@ interface GitSettingsProps { gitAvailable: boolean | null; onWorkspacesChanged?: () => Promise; showGitStatus: boolean; + showStashCount: boolean; updateShowGitStatus: (show: boolean) => Promise; + updateShowStashCount: (show: boolean) => Promise; } export default function GitSettings({ gitAvailable, onWorkspacesChanged, showGitStatus, + showStashCount, updateShowGitStatus, + updateShowStashCount, }: GitSettingsProps) { const toggleGitStatus = async () => { const newValue = !showGitStatus; @@ -27,6 +31,20 @@ export default function GitSettings({ }); }; + const toggleStashCount = async () => { + const newValue = !showStashCount; + await updateShowStashCount(newValue); + + if (onWorkspacesChanged) { + await onWorkspacesChanged(); + } + + await showToast({ + style: Toast.Style.Success, + title: newValue ? "Stash count enabled" : "Stash count disabled", + }); + }; + const gitStatusLabel = gitAvailable === null ? "Checking…" : gitAvailable ? "Installed" : "Not installed"; return ( @@ -35,7 +53,18 @@ export default function GitSettings({ gitAvailable ? ( - + + {showGitStatus ? ( + + ) : null} ) : undefined @@ -49,12 +78,20 @@ export default function GitSettings({ {gitAvailable ? ( - - - + <> + + + + + + + ) : null} } @@ -62,7 +99,7 @@ export default function GitSettings({ } icon={Icon.Shuffle} id="git" - keywords={["git", "branch", "status"]} + keywords={["git", "branch", "status", "stash"]} title="Git" /> ); @@ -82,7 +119,7 @@ function gitDetailMarkdown(gitAvailable: boolean | null): string { } if (gitAvailable) { - return "Show branch, uncommitted files, and ahead/behind next to each project. Checkout, pull, and the commit log stay on the project actions."; + return "Show branch, modified/untracked files, clean state, ahead/behind, and optional stash count. Checkout, pull, remote, and commit log stay on the project actions. Tooltips include when status was last refreshed."; } return "Install Git to show branch and sync status on each project."; diff --git a/extensions/workspace/src/components/Settings/ManagedWorkspacesSection.tsx b/extensions/workspace/src/components/Settings/ManagedWorkspacesSection.tsx index 519feb741..ebfa63834 100644 --- a/extensions/workspace/src/components/Settings/ManagedWorkspacesSection.tsx +++ b/extensions/workspace/src/components/Settings/ManagedWorkspacesSection.tsx @@ -166,9 +166,14 @@ export default function ManagedWorkspacesSection({ /> - + @@ -198,7 +203,7 @@ export default function ManagedWorkspacesSection({ actions={ - } title="Add Workspace" /> + } title="Add Workspace" /> } diff --git a/extensions/workspace/src/components/Settings/RecentProjectsSettings.tsx b/extensions/workspace/src/components/Settings/RecentProjectsSettings.tsx index 7925829c2..3186f0d83 100644 --- a/extensions/workspace/src/components/Settings/RecentProjectsSettings.tsx +++ b/extensions/workspace/src/components/Settings/RecentProjectsSettings.tsx @@ -19,6 +19,7 @@ export default function RecentProjectsSettings({ updateShowRecentProjects(!showRecentProjects)} title={showRecentProjects ? "Disable Recent Projects" : "Enable Recent Projects"} /> @@ -26,6 +27,7 @@ export default function RecentProjectsSettings({ {[3, 5, 7, 10].map((count) => ( updateRecentProjectsCount(count)} title={`Show ${count} Recent Projects`} diff --git a/extensions/workspace/src/components/Settings/StatsSettings.tsx b/extensions/workspace/src/components/Settings/StatsSettings.tsx new file mode 100644 index 000000000..dbb41ca67 --- /dev/null +++ b/extensions/workspace/src/components/Settings/StatsSettings.tsx @@ -0,0 +1,311 @@ +import { Action, ActionPanel, Alert, Color, confirmAlert, Icon, List, showToast, Toast } from "@vicinae/api"; +import path from "path"; +import { useState } from "react"; + +import { + formatInstalledSince, + projectDisplayName, + type RankedProject, + type UsagePeriod, + type UsageSnapshot, +} from "@/utils/usage"; + +interface StatsSettingsProps { + gitRepoCount: number; + onClearUsage: () => Promise; + projectCount: number; + snapshot: UsageSnapshot; + workspaceCount: number; +} + +export default function StatsSettings({ + gitRepoCount, + onClearUsage, + projectCount, + snapshot, + workspaceCount, +}: StatsSettingsProps) { + const top = snapshot.mostOpened.all; + + const clearStats = async () => { + if ( + !(await confirmAlert({ + message: "This clears open history used for statistics. Workspaces and pins are kept.", + primaryAction: { style: Alert.ActionStyle.Destructive, title: "Clear Statistics" }, + title: "Clear Statistics", + })) + ) { + return; + } + + await onClearUsage(); + await showToast({ style: Toast.Style.Success, title: "Statistics cleared" }); + }; + + return ( + + + + } + title="Open Statistics" + /> + + + + + + } + detail={ + + + + + + + + + + } + /> + } + icon={Icon.BarChart} + id="statistics" + keywords={["stats", "statistics", "usage", "opens", "most opened"]} + title="Statistics" + /> + ); +} + +function StatsDetailView({ + gitRepoCount, + onClearUsage, + projectCount, + snapshot, + workspaceCount, +}: StatsSettingsProps) { + const [period, setPeriod] = useState("week"); + const ranked = snapshot.topProjects[period]; + const opens = snapshot.opensInPeriod[period]; + const top = snapshot.mostOpened[period]; + + return ( + setPeriod(value as UsagePeriod)} tooltip="Period" value={period}> + {PERIOD_OPTIONS.map((option) => ( + + ))} + + } + searchBarPlaceholder="Search projects…" + > + + + + + } + detail={ + + + + + + + + + + + + + } + /> + } + icon={Icon.BarChart} + subtitle={`${opens} open${opens === 1 ? "" : "s"} · ${periodTitle(period)}`} + title="Overview" + /> + + + + {ranked.length === 0 ? ( + + ) : ( + ranked.map((entry, index) => ( + + )) + )} + + + ); +} + +function RankedProjectItem({ + entry, + period, + rank, +}: { + entry: RankedProject; + period: UsagePeriod; + rank: number; +}) { + const name = projectDisplayName(entry.path); + return ( + + + + + + + } + /> + } + icon={Icon.Folder} + keywords={[name, entry.path, path.basename(path.dirname(entry.path))]} + subtitle={`${entry.count} open${entry.count === 1 ? "" : "s"}`} + title={name} + /> + ); +} + +const PERIOD_OPTIONS: { period: UsagePeriod; title: string; icon: Icon }[] = [ + { period: "day", title: "Today", icon: Icon.Clock }, + { period: "week", title: "This week", icon: Icon.Calendar }, + { period: "month", title: "This month", icon: Icon.Calendar }, + { period: "year", title: "This year", icon: Icon.Calendar }, + { period: "all", title: "All time", icon: Icon.BarChart }, +]; + +function summaryMarkdown( + snapshot: UsageSnapshot, + projectCount: number, + workspaceCount: number, + gitRepoCount: number, +): string { + const top = snapshot.mostOpened.all; + return [ + "Usage since this extension was installed on this machine.", + "", + `- **${projectCount}** projects listed across **${workspaceCount}** workspace${workspaceCount === 1 ? "" : "s"}`, + `- **${gitRepoCount}** git repositories`, + `- **${snapshot.totalOpens}** opens tracked`, + `- Installed for **${formatInstalledSince(snapshot.installedAt)}**`, + top ? `- Most opened all time: **${projectDisplayName(top.path)}** (${top.count})` : "- No opens recorded yet", + ].join("\n"); +} + +function periodOverviewMarkdown( + snapshot: UsageSnapshot, + period: UsagePeriod, + projectCount: number, + workspaceCount: number, + gitRepoCount: number, +): string { + const opens = snapshot.opensInPeriod[period]; + const top = snapshot.mostOpened[period]; + return [ + `Opens **${periodLabel(period)}**.`, + "", + `- **${opens}** open${opens === 1 ? "" : "s"}`, + `- **${snapshot.topProjects[period].length}** project${snapshot.topProjects[period].length === 1 ? "" : "s"} opened`, + top ? `- Top: **${projectDisplayName(top.path)}** (${top.count})` : "- No opens in this period yet", + "", + `_Catalog: ${projectCount} projects · ${workspaceCount} workspaces · ${gitRepoCount} git repos_`, + ].join("\n"); +} + +function periodTitle(period: UsagePeriod): string { + switch (period) { + case "all": + return "All time"; + case "year": + return "This year"; + case "month": + return "This month"; + case "week": + return "This week"; + case "day": + return "Today"; + } +} + +function periodLabel(period: UsagePeriod): string { + switch (period) { + case "all": + return "all time"; + case "year": + return "this year"; + case "month": + return "this month"; + case "week": + return "this week"; + case "day": + return "today"; + } +} diff --git a/extensions/workspace/src/components/Settings/TagsSettings.tsx b/extensions/workspace/src/components/Settings/TagsSettings.tsx new file mode 100644 index 000000000..33925fcee --- /dev/null +++ b/extensions/workspace/src/components/Settings/TagsSettings.tsx @@ -0,0 +1,487 @@ +import { + Action, + ActionPanel, + Alert, + Color, + confirmAlert, + Form, + Icon, + List, + showToast, + Toast, + useNavigation, +} from "@vicinae/api"; +import path from "path"; +import { useEffect, useMemo, useState } from "react"; + +import { useTags } from "@/hooks/useTags"; +import { useWorkspace } from "@/hooks/useWorkspace"; +import { Tag } from "@/types"; +import { listItemId } from "@/utils/paths"; +import { projectListIcon } from "@/utils/projectDisplay"; +import { projectsWithTag, TAG_COLORS } from "@/utils/tags"; + +/** Settings list row — reads live tags from the parent Settings render. */ +export default function TagsSettings({ + projectTags, + tags, +}: { + projectTags: Record; + tags: Tag[]; +}) { + const usageCount = (tagId: string) => + Object.values(projectTags).reduce((sum, ids) => sum + (ids.includes(tagId) ? 1 : 0), 0); + + return ( + + + } title="Manage Tags" /> + } title="Create Tag" /> + + + } + detail={ + + + + {tags.slice(0, 8).map((tag) => ( + + + + ))} + + } + /> + } + icon={Icon.Tag} + id="tags" + keywords={["tag", "label", "group", "category"]} + title="Tags" + /> + ); +} + +/** + * Pushed views must subscribe via useTags() directly. + * Context from WorkspaceProvider often does not re-render navigation stack screens. + */ +function TagsManagerView() { + const { createTag, deleteTag, projectTags, tags, updateTag } = useTags(); + + const usageCount = (tagId: string) => + Object.values(projectTags).reduce((sum, ids) => sum + (ids.includes(tagId) ? 1 : 0), 0); + + const removeTag = async (tag: Tag) => { + if ( + !(await confirmAlert({ + message: `Delete “${tag.name}”? It will be removed from all projects.`, + primaryAction: { style: Alert.ActionStyle.Destructive, title: "Delete Tag" }, + title: "Delete Tag", + })) + ) { + return; + } + + await deleteTag(tag.id); + await showToast({ style: Toast.Style.Success, title: `Deleted ${tag.name}` }); + }; + + return ( + `${tag.id}:${tag.name}:${tag.color}`).join("|")}`} + navigationTitle="Manage Tags" + searchBarPlaceholder="Search tags..." + > + + + } title="Create Tag" /> + + } + icon={Icon.Plus} + subtitle="Add a global tag" + title="Create Tag" + /> + + + {tags.length === 0 ? ( + + } title="Create Tag" /> + + } + icon={Icon.Minus} + subtitle="Create a tag to get started" + title="No tags yet" + /> + ) : ( + tags.map((tag) => ( + + + } + title="Assign Projects…" + /> + + + } + title="Edit Tag" + /> + } title="Create Tag" /> + + + removeTag(tag)} + shortcut={{ key: "backspace", modifiers: ["cmd"] }} + style={Action.Style.Destructive} + title="Delete Tag" + /> + + + } + detail={ + + + + + + + + } + /> + } + icon={Icon.Tag} + keywords={[tag.name, tag.id, "edit", "delete"]} + subtitle={`${usageCount(tag.id)} project${usageCount(tag.id) === 1 ? "" : "s"}`} + title={tag.name} + /> + )) + )} + + + ); +} + +function BulkAssignTagView({ tagId }: { tagId: string }) { + const { projectTags, syncTagProjects, tags } = useTags(); + const { isLoading, projects } = useWorkspace(); + const { pop } = useNavigation(); + const tag = tags.find((entry) => entry.id === tagId); + + const [selected, setSelected] = useState(() => new Set(projectsWithTag(projectTags, tagId))); + const [workspaceRoot, setWorkspaceRoot] = useState("all"); + + const sortedProjects = useMemo( + () => [...projects].sort((a, b) => a.name.localeCompare(b.name) || a.fullPath.localeCompare(b.fullPath)), + [projects], + ); + + const workspaceRoots = useMemo(() => { + const roots = [...new Set(sortedProjects.map((project) => project.parentFolder))]; + return roots.sort((a, b) => path.basename(a).localeCompare(path.basename(b))); + }, [sortedProjects]); + + useEffect(() => { + if (workspaceRoot !== "all" && !workspaceRoots.includes(workspaceRoot)) { + setWorkspaceRoot("all"); + } + }, [workspaceRoot, workspaceRoots]); + + const filteredProjects = useMemo( + () => + workspaceRoot === "all" + ? sortedProjects + : sortedProjects.filter((project) => project.parentFolder === workspaceRoot), + [sortedProjects, workspaceRoot], + ); + + const byWorkspace = useMemo(() => { + const groups = new Map(); + for (const project of filteredProjects) { + const list = groups.get(project.parentFolder) ?? []; + list.push(project); + groups.set(project.parentFolder, list); + } + return [...groups.entries()].sort(([a], [b]) => path.basename(a).localeCompare(path.basename(b))); + }, [filteredProjects]); + + if (!tag) { + return ( + + + + + } + description="This tag no longer exists." + title="Tag not found" + /> + + ); + } + + const toggle = (projectPath: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(projectPath)) { + next.delete(projectPath); + } else { + next.add(projectPath); + } + return next; + }); + }; + + /** Limited to the workspace filter; text search is applied by Vicinae on top. */ + const selectAllFiltered = () => + setSelected((current) => { + const next = new Set(current); + for (const project of filteredProjects) { + next.add(project.fullPath); + } + return next; + }); + const clearFiltered = () => + setSelected((current) => { + const next = new Set(current); + for (const project of filteredProjects) { + next.delete(project.fullPath); + } + return next; + }); + + const save = async () => { + await syncTagProjects(tagId, [...selected]); + await showToast({ + message: `${selected.size} project${selected.size === 1 ? "" : "s"}`, + style: Toast.Style.Success, + title: `Updated ${tag.name}`, + }); + pop(); + }; + + return ( + + + {workspaceRoots.map((root) => ( + + ))} + + } + searchBarPlaceholder="Search projects…" + > + + + + + + + } + icon={{ source: Icon.Tag, tintColor: tag.color as Color }} + subtitle={ + workspaceRoot === "all" + ? "Toggle projects below, then save" + : `Only ${path.basename(workspaceRoot)} · toggle, then save` + } + title="Save Assignments" + /> + + + {sortedProjects.length === 0 && !isLoading ? ( + + ) : byWorkspace.length === 0 ? ( + + ) : ( + byWorkspace.map(([workspacePath, workspaceProjects]) => ( + + {workspaceProjects.map((project) => { + const isSelected = selected.has(project.fullPath); + const workspaceName = path.basename(project.parentFolder); + return ( + + toggle(project.fullPath)} + title={isSelected ? "Deselect Project" : "Select Project"} + /> + + + + + } + icon={projectListIcon(project)} + keywords={[project.fullPath, project.parentFolder, workspaceName, `@${workspaceName}`]} + subtitle={path.dirname(project.fullPath)} + title={project.name} + /> + ); + })} + + )) + )} + + ); +} + +function CreateTagForm() { + const { createTag } = useTags(); + const { pop } = useNavigation(); + const [error, setError] = useState(); + + async function handleSubmit(values: { color?: string; name?: string }) { + const name = values.name?.trim() ?? ""; + if (!name) { + setError("Required"); + return; + } + + const color = (TAG_COLORS.find((entry) => entry.color === values.color)?.color ?? Color.Blue) as Color; + const tag = await createTag(name, color); + if (!tag) { + setError("Could not create tag"); + return; + } + + await showToast({ style: Toast.Style.Success, title: `Created ${tag.name}` }); + pop(); + } + + return ( +
+ + + } + navigationTitle="Create Tag" + > + setError(undefined)} + placeholder="work, personal, client…" + title="Name" + /> + + {TAG_COLORS.map((entry) => ( + + ))} + + + ); +} + +function EditTagForm({ tagId }: { tagId: string }) { + const { tags, updateTag } = useTags(); + const { pop } = useNavigation(); + const [error, setError] = useState(); + const tag = tags.find((entry) => entry.id === tagId); + + if (!tag) { + return ( + + + + + } + description="This tag no longer exists." + title="Tag not found" + /> + + ); + } + + async function handleSubmit(values: { color?: string; name?: string }) { + const name = values.name?.trim() ?? ""; + if (!name) { + setError("Required"); + return; + } + + const color = (TAG_COLORS.find((entry) => entry.color === values.color)?.color ?? tag!.color) as Color; + await updateTag(tag!.id, { color, name }); + await showToast({ style: Toast.Style.Success, title: "Tag updated" }); + pop(); + } + + return ( +
+ + + } + navigationTitle={`Edit ${tag.name}`} + > + setError(undefined)} + title="Name" + /> + + {TAG_COLORS.map((entry) => ( + + ))} + + + ); +} diff --git a/extensions/workspace/src/hooks/useCachedState.ts b/extensions/workspace/src/hooks/useCachedState.ts index bd987f4a8..7a7e105b1 100644 --- a/extensions/workspace/src/hooks/useCachedState.ts +++ b/extensions/workspace/src/hooks/useCachedState.ts @@ -35,6 +35,18 @@ function write(key: string, next: Slot) { emit(key); } +export function peekCachedState(key: string, fallback: T): T { + const slot = slots.get(key); + if (!slot) { + return fallback; + } + return slot.value as T; +} + +export function isCachedStateHydrated(key: string): boolean { + return slots.get(key)?.hydrated ?? false; +} + export function useCachedState(key: string, initialValue: T): [T, (value: T) => void, boolean] { getOrCreateSlot(key, initialValue); @@ -77,6 +89,10 @@ export function useCachedState(key: string, initialValue: T): [T, (value: T) loading.delete(key); const current = getOrCreateSlot(key, initialValue); + if (current.hydrated) { + return; + } + if (raw !== undefined && raw !== null) { try { const parsed = typeof raw === "string" ? (JSON.parse(raw) as T) : (raw as T); @@ -95,6 +111,12 @@ export function useCachedState(key: string, initialValue: T): [T, (value: T) const setCachedState = useCallback( (next: T) => { + const current = getOrCreateSlot(key, initialValue); + // Writes before LocalStorage loads would persist defaults and skip hydration. + if (!current.hydrated) { + return; + } + write(key, { hydrated: true, value: next }); void LocalStorage.setItem(key, JSON.stringify(next)); }, diff --git a/extensions/workspace/src/hooks/usePreferences.ts b/extensions/workspace/src/hooks/usePreferences.ts index 6079e883b..2ab1f3e08 100644 --- a/extensions/workspace/src/hooks/usePreferences.ts +++ b/extensions/workspace/src/hooks/usePreferences.ts @@ -3,11 +3,22 @@ import { useCachedState } from "@/hooks/useCachedState"; import { App } from "@/types"; import { STORAGE_KEY_APP, + STORAGE_KEY_IGNORE_PATTERNS, + STORAGE_KEY_INCLUDE_NESTED, STORAGE_KEY_ONBOARDING_COMPLETED, + STORAGE_KEY_REQUIRE_MARKERS, + STORAGE_KEY_SCAN_DEPTH, STORAGE_KEY_SHOW_GIT_STATUS, STORAGE_KEY_SHOW_RECENT_PROJECTS, + STORAGE_KEY_SHOW_STASH_COUNT, STORAGE_KEY_TERMINAL_APP, } from "@/utils/constants"; +import { + clampScanDepth, + DEFAULT_IGNORE_PATTERNS, + DEFAULT_SCAN_DEPTH, + normalizeIgnorePatterns, +} from "@/utils/discovery"; export function usePreferences() { const [defaultApp, setDefaultApp] = useCachedState(STORAGE_KEY_APP, null); @@ -20,26 +31,54 @@ export function usePreferences() { STORAGE_KEY_SHOW_GIT_STATUS, true, ); + const [showStashCount, setShowStashCount] = useCachedState(STORAGE_KEY_SHOW_STASH_COUNT, false); const [showRecentProjects, setShowRecentProjects] = useCachedState(STORAGE_KEY_SHOW_RECENT_PROJECTS, false); + const [scanDepth, setScanDepth, discoveryHydrated] = useCachedState( + STORAGE_KEY_SCAN_DEPTH, + DEFAULT_SCAN_DEPTH, + ); + const [ignorePatterns, setIgnorePatterns] = useCachedState( + STORAGE_KEY_IGNORE_PATTERNS, + DEFAULT_IGNORE_PATTERNS, + ); + const [includeNested, setIncludeNested] = useCachedState(STORAGE_KEY_INCLUDE_NESTED, false); + const [requireMarkers, setRequireMarkers] = useCachedState(STORAGE_KEY_REQUIRE_MARKERS, false); const updateDefaultApp = async (app: App | null): Promise => setDefaultApp(app); const updateTerminalApp = async (app: App | null): Promise => setTerminalApp(app); const setOnboardingCompletedState = async (completed: boolean): Promise => setOnboardingCompleted(completed); const updateShowGitStatus = async (show: boolean): Promise => setShowGitStatus(show); + const updateShowStashCount = async (show: boolean): Promise => setShowStashCount(show); const updateShowRecentProjects = async (show: boolean): Promise => setShowRecentProjects(show); + const updateScanDepth = async (depth: number): Promise => setScanDepth(clampScanDepth(depth)); + const updateIgnorePatterns = async (patterns: string[]): Promise => + setIgnorePatterns(normalizeIgnorePatterns(patterns, DEFAULT_IGNORE_PATTERNS)); + const updateIncludeNested = async (value: boolean): Promise => setIncludeNested(value); + const updateRequireMarkers = async (value: boolean): Promise => setRequireMarkers(value); return { defaultApp, + discoveryHydrated, gitStatusHydrated, + ignorePatterns, + includeNested, onboardingCompleted, onboardingHydrated, + requireMarkers, + scanDepth: clampScanDepth(scanDepth), setOnboardingCompleted: setOnboardingCompletedState, showGitStatus, showRecentProjects, + showStashCount, terminalApp, updateDefaultApp, + updateIgnorePatterns, + updateIncludeNested, + updateRequireMarkers, + updateScanDepth, updateShowGitStatus, updateShowRecentProjects, + updateShowStashCount, updateTerminalApp, }; } diff --git a/extensions/workspace/src/hooks/useProjectDiscovery.ts b/extensions/workspace/src/hooks/useProjectDiscovery.ts index bcd793b6d..27db80dc7 100644 --- a/extensions/workspace/src/hooks/useProjectDiscovery.ts +++ b/extensions/workspace/src/hooks/useProjectDiscovery.ts @@ -1,10 +1,9 @@ import { LocalStorage } from "@vicinae/api"; -import { readdir } from "fs/promises"; -import path from "path"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Project } from "@/types"; import { STORAGE_KEY_PROJECTS_CACHE } from "@/utils/constants"; +import { type ScanOptions, scanWorkspaceProjects } from "@/utils/discovery"; import { getGitStatus, isGitAvailable, isGitRepo } from "@/utils/git"; const GIT_CONCURRENCY = 16; @@ -18,18 +17,29 @@ type ProjectsCache = { let memoryCache: ProjectsCache | null = null; type WorkspaceScan = { - ok: boolean; + complete: boolean; projects: Project[]; workspacePath: string; }; -export function useProjectDiscovery(workspaces: string[], showGitStatus: boolean, ready: boolean, enabled = true) { +export function useProjectDiscovery( + workspaces: string[], + showGitStatus: boolean, + ready: boolean, + scanOptions: ScanOptions, + showStashCount = false, + enabled = true, +) { const [projects, setProjects] = useState(() => memoryCache?.projects ?? []); const [cachedWorkspaces, setCachedWorkspaces] = useState(() => memoryCache?.workspaces ?? []); const [isLoading, setIsLoading] = useState(() => memoryCache == null); const [hasScanned, setHasScanned] = useState(false); const [scannedWorkspaceRoots, setScannedWorkspaceRoots] = useState([]); const [version, setVersion] = useState(0); + const showGitStatusRef = useRef(showGitStatus); + const showStashCountRef = useRef(showStashCount); + showGitStatusRef.current = showGitStatus; + showStashCountRef.current = showStashCount; useEffect(() => { if (!enabled || memoryCache) { @@ -73,24 +83,31 @@ export function useProjectDiscovery(workspaces: string[], showGitStatus: boolean } async function refresh() { - const scans = await Promise.all(workspaces.map(scanWorkspace)); + const scans = await Promise.all(workspaces.map((workspacePath) => scanWorkspace(workspacePath, scanOptions))); if (cancelled) { return; } const previous = memoryCache?.projects ?? []; - const scannedRoots = scans.filter((scan) => scan.ok).map((scan) => scan.workspacePath); - const listed = scans.flatMap((scan) => - scan.ok ? scan.projects : previous.filter((project) => project.parentFolder === scan.workspacePath), - ); + const scannedRoots = scans.filter((scan) => scan.complete).map((scan) => scan.workspacePath); + const listed = scans.flatMap((scan) => { + if (scan.complete) { + return scan.projects; + } + // Truncated scans still list what was found, but failed scans keep the previous set. + if (scan.projects.length > 0) { + return scan.projects; + } + return previous.filter((project) => project.parentFolder === scan.workspacePath); + }); const previousByPath = new Map((memoryCache?.projects ?? []).map((project) => [project.fullPath, project])); const listedWithStaleGit = listed.map((project) => { - const previous = previousByPath.get(project.fullPath); + const previousProject = previousByPath.get(project.fullPath); return { ...project, - gitStatus: showGitStatus ? previous?.gitStatus : undefined, - isGitRepo: previous?.isGitRepo ?? previous?.gitStatus != null, + gitStatus: showGitStatus ? previousProject?.gitStatus : undefined, + isGitRepo: previousProject?.isGitRepo ?? previousProject?.gitStatus != null, }; }); @@ -114,7 +131,7 @@ export function useProjectDiscovery(workspaces: string[], showGitStatus: boolean ...(await Promise.all( chunk.map(async (project) => { if (showGitStatus) { - const gitStatus = await getGitStatus(project.fullPath); + const gitStatus = await getGitStatus(project.fullPath, { includeStash: showStashCount }); return { ...project, gitStatus, isGitRepo: gitStatus != null }; } @@ -135,18 +152,69 @@ export function useProjectDiscovery(workspaces: string[], showGitStatus: boolean return () => { cancelled = true; }; - }, [enabled, ready, showGitStatus, version, workspaces]); + }, [ + enabled, + ready, + scanOptions.ignorePatterns, + scanOptions.includeNested, + scanOptions.requireMarkers, + scanOptions.scanDepth, + showGitStatus, + showStashCount, + version, + workspaces, + ]); const loadData = useCallback(async () => { setVersion((current) => current + 1); }, []); + const refreshProjectGit = useCallback(async (projectPath: string) => { + const current = memoryCache?.projects ?? []; + const index = current.findIndex((project) => project.fullPath === projectPath); + if (index === -1) { + return; + } + + const showStatus = showGitStatusRef.current; + const includeStash = showStashCountRef.current; + let nextProject: Project; + + if (showStatus) { + const gitStatus = await getGitStatus(projectPath, { includeStash }); + nextProject = { + ...current[index], + gitStatus, + isGitRepo: gitStatus != null, + }; + } else { + nextProject = { + ...current[index], + gitStatus: undefined, + isGitRepo: await isGitRepo(projectPath), + }; + } + + const nextProjects = current.slice(); + nextProjects[index] = nextProject; + remember( + { + projects: nextProjects, + showGitStatus: memoryCache?.showGitStatus ?? showStatus, + workspaces: memoryCache?.workspaces ?? [], + }, + setProjects, + setCachedWorkspaces, + ); + }, []); + return { cachedWorkspaces, hasScanned, isLoading, loadData, projects, + refreshProjectGit, scannedWorkspaceRoots, }; } @@ -174,23 +242,11 @@ function persist(cache: ProjectsCache) { void LocalStorage.setItem(STORAGE_KEY_PROJECTS_CACHE, JSON.stringify(cache)); } -async function scanWorkspace(workspacePath: string): Promise { +async function scanWorkspace(workspacePath: string, options: ScanOptions): Promise { try { - const entries = await readdir(workspacePath, { withFileTypes: true }); - - return { - ok: true, - projects: entries - .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((entry) => ({ - fullPath: path.join(workspacePath, entry.name), - name: entry.name, - parentFolder: workspacePath, - })), - workspacePath, - }; + const { projects, truncated } = await scanWorkspaceProjects(workspacePath, options); + return { complete: !truncated, projects, workspacePath }; } catch { - return { ok: false, projects: [], workspacePath }; + return { complete: false, projects: [], workspacePath }; } } diff --git a/extensions/workspace/src/hooks/useRecentProjects.ts b/extensions/workspace/src/hooks/useRecentProjects.ts index 17f8bcd4f..b6aca560a 100644 --- a/extensions/workspace/src/hooks/useRecentProjects.ts +++ b/extensions/workspace/src/hooks/useRecentProjects.ts @@ -24,6 +24,10 @@ export function useRecentProjects() { const updateRecentProjectsCount = async (count: number): Promise => setRecentProjectsCount(count); const togglePinProject = async (projectPath: string): Promise => { + if (!pinnedHydrated || !recentHydrated) { + return; + } + let newPinned: string[]; if (pinnedProjects.includes(projectPath)) { newPinned = pinnedProjects.filter((p: string) => p !== projectPath); @@ -40,6 +44,10 @@ export function useRecentProjects() { }; const reorderPinnedProject = async (projectPath: string, direction: "down" | "up"): Promise => { + if (!pinnedHydrated) { + return; + } + const from = pinnedProjects.indexOf(projectPath); if (from === -1) return; @@ -54,6 +62,10 @@ export function useRecentProjects() { }; const recordProjectOpen = async (projectPath: string): Promise => { + if (!recentHydrated || !pinnedHydrated) { + return; + } + if (pinnedProjects.includes(projectPath)) { return; } diff --git a/extensions/workspace/src/hooks/useTags.ts b/extensions/workspace/src/hooks/useTags.ts new file mode 100644 index 000000000..9c2cbf64d --- /dev/null +++ b/extensions/workspace/src/hooks/useTags.ts @@ -0,0 +1,130 @@ +import { Color } from "@vicinae/api"; +import { useCallback } from "react"; + +import { peekCachedState, useCachedState } from "@/hooks/useCachedState"; +import { Tag } from "@/types"; +import { STORAGE_KEY_PROJECT_TAGS, STORAGE_KEY_TAGS } from "@/utils/constants"; +import { + addTagToProject, + createTagId, + deleteTagFromCatalog, + normalizeProjectTags, + normalizeTags, + removeTagFromProject, + syncTagProjects, + tagsForProject, +} from "@/utils/tags"; + +export function useTags() { + const [rawTags, setTags, tagsHydrated] = useCachedState(STORAGE_KEY_TAGS, []); + const [rawProjectTags, setProjectTags, projectTagsHydrated] = useCachedState>( + STORAGE_KEY_PROJECT_TAGS, + {}, + ); + + const tags = normalizeTags(rawTags); + const projectTags = normalizeProjectTags(rawProjectTags, tags); + + const readTags = () => normalizeTags(peekCachedState(STORAGE_KEY_TAGS, [])); + const readProjectTags = (catalog: Tag[]) => + normalizeProjectTags(peekCachedState>(STORAGE_KEY_PROJECT_TAGS, {}), catalog); + + const replaceTags = useCallback(async (nextTags: Tag[], nextProjectTags?: Record) => { + const normalized = normalizeTags(nextTags); + setTags(normalized); + setProjectTags(normalizeProjectTags(nextProjectTags ?? readProjectTags(normalized), normalized)); + }, [setProjectTags, setTags]); + + const createTag = useCallback( + async (name: string, color: Color = Color.Blue): Promise => { + const trimmed = name.trim(); + if (!trimmed) return null; + + const current = readTags(); + const existing = current.find((tag) => tag.name.toLowerCase() === trimmed.toLowerCase()); + if (existing) return existing; + + const tag: Tag = { color, id: createTagId(trimmed), name: trimmed }; + setTags(normalizeTags([...current, tag])); + return tag; + }, + [setTags], + ); + + const updateTag = useCallback( + async (tagId: string, patch: Partial>) => { + const current = readTags(); + setTags( + normalizeTags( + current.map((tag) => + tag.id === tagId + ? { + ...tag, + color: patch.color ?? tag.color, + name: patch.name?.trim() || tag.name, + } + : tag, + ), + ), + ); + }, + [setTags], + ); + + const deleteTag = useCallback( + async (tagId: string) => { + const currentTags = readTags(); + const currentProjectTags = readProjectTags(currentTags); + const next = deleteTagFromCatalog(currentTags, currentProjectTags, tagId); + setTags(next.tags); + setProjectTags(next.projectTags); + }, + [setProjectTags, setTags], + ); + + const assignTag = useCallback( + async (projectPath: string, tagId: string) => { + const currentTags = readTags(); + const currentProjectTags = readProjectTags(currentTags); + setProjectTags(addTagToProject(currentProjectTags, projectPath, tagId)); + }, + [setProjectTags], + ); + + const unassignTag = useCallback( + async (projectPath: string, tagId: string) => { + const currentTags = readTags(); + const currentProjectTags = readProjectTags(currentTags); + setProjectTags(removeTagFromProject(currentProjectTags, projectPath, tagId)); + }, + [setProjectTags], + ); + + const syncTagProjectsBulk = useCallback( + async (tagId: string, projectPaths: string[]) => { + const currentTags = readTags(); + const currentProjectTags = readProjectTags(currentTags); + setProjectTags(syncTagProjects(currentProjectTags, tagId, projectPaths)); + }, + [setProjectTags], + ); + + const getProjectTags = useCallback( + (projectPath: string) => tagsForProject(projectPath, tags, projectTags), + [projectTags, tags], + ); + + return { + assignTag, + createTag, + deleteTag, + getProjectTags, + isHydrated: tagsHydrated && projectTagsHydrated, + projectTags, + replaceTags, + syncTagProjects: syncTagProjectsBulk, + tags, + unassignTag, + updateTag, + }; +} diff --git a/extensions/workspace/src/hooks/useUsageStats.ts b/extensions/workspace/src/hooks/useUsageStats.ts new file mode 100644 index 000000000..30767f923 --- /dev/null +++ b/extensions/workspace/src/hooks/useUsageStats.ts @@ -0,0 +1,53 @@ +import { useCallback, useMemo } from "react"; + +import { peekCachedState, useCachedState, isCachedStateHydrated } from "@/hooks/useCachedState"; +import { STORAGE_KEY_USAGE_STATS } from "@/utils/constants"; +import { + buildUsageSnapshot, + createEmptyUsageStore, + normalizeUsageStore, + recordOpen, + type UsageSnapshot, +} from "@/utils/usage"; +import type { UsageStore } from "@/types"; + +export function useUsageStats() { + const [rawStore, setStore, hydrated] = useCachedState( + STORAGE_KEY_USAGE_STATS, + createEmptyUsageStore(), + ); + + const store = useMemo(() => normalizeUsageStore(rawStore), [rawStore]); + const snapshot = useMemo(() => buildUsageSnapshot(store), [store]); + + const recordProjectOpen = useCallback( + async (projectPath: string) => { + if (!isCachedStateHydrated(STORAGE_KEY_USAGE_STATS)) { + return; + } + + setStore(recordOpen(normalizeUsageStore(peekCachedState(STORAGE_KEY_USAGE_STATS, createEmptyUsageStore())), projectPath)); + }, + [setStore], + ); + + const replaceUsage = useCallback( + async (next: UsageStore | null | undefined) => { + setStore(normalizeUsageStore(next ?? createEmptyUsageStore())); + }, + [setStore], + ); + + const clearUsage = useCallback(async () => { + setStore(createEmptyUsageStore()); + }, [setStore]); + + return { + clearUsage, + hydrated, + recordProjectOpen, + replaceUsage, + snapshot, + store, + }; +} diff --git a/extensions/workspace/src/hooks/useWorkspace.tsx b/extensions/workspace/src/hooks/useWorkspace.tsx index 909a44a25..b7da38a31 100644 --- a/extensions/workspace/src/hooks/useWorkspace.tsx +++ b/extensions/workspace/src/hooks/useWorkspace.tsx @@ -1,46 +1,71 @@ import { showToast, Toast } from "@vicinae/api"; import path from "path"; -import { createContext, type ReactNode, useContext, useEffect } from "react"; +import { createContext, type ReactNode, useContext, useEffect, useMemo } from "react"; import { useCachedPromise } from "@/hooks/useCachedPromise"; import { usePreferences } from "@/hooks/usePreferences"; import { clearProjectsCache, useProjectDiscovery } from "@/hooks/useProjectDiscovery"; import { useRecentProjects } from "@/hooks/useRecentProjects"; +import { useTags } from "@/hooks/useTags"; +import { useUsageStats } from "@/hooks/useUsageStats"; import { useWorkspaces } from "@/hooks/useWorkspaces"; -import { App, ExportedSettings, Project, RecentProject } from "@/types"; +import { App, ExportedSettings, Project, RecentProject, Tag } from "@/types"; import { isGitAvailable } from "@/utils/git"; import { keepSavedProjectPaths } from "@/utils/projects"; import { DEFAULT_SETTINGS, exportSettingsToDownloads, importSettingsFromFile } from "@/utils/storage"; +import type { UsageSnapshot } from "@/utils/usage"; +import { Color } from "@vicinae/api"; export interface UseWorkspaceReturn { applyImportedSettings: (settings: ExportedSettings) => Promise; + assignTag: (projectPath: string, tagId: string) => Promise; + clearUsageStats: () => Promise; + createTag: (name: string, color?: Color) => Promise; defaultApp: App | null; + deleteTag: (tagId: string) => Promise; exportSettings: () => Promise; + getProjectTags: (projectPath: string) => Tag[]; gitAvailable: boolean | null; + ignorePatterns: string[]; importSettings: (filePath: string) => Promise; + includeNested: boolean; isLoading: boolean; loadData: () => Promise; onboardingCompleted: boolean; onboardingHydrated: boolean; pinnedProjects: string[]; + projectTags: Record; projects: Project[]; recentProjects: RecentProject[]; recentProjectsCount: number; recordProjectOpen: (projectPath: string) => Promise; + refreshProjectGit: (projectPath: string) => Promise; reorderPinnedProject: (projectPath: string, direction: "down" | "up") => Promise; + requireMarkers: boolean; resetExtension: () => Promise; + scanDepth: number; setOnboardingCompleted: (completed: boolean) => Promise; showGitStatus: boolean; showRecentProjects: boolean; + showStashCount: boolean; + tags: Tag[]; terminalApp: App | null; togglePinProject: (projectPath: string) => Promise; + unassignTag: (projectPath: string, tagId: string) => Promise; updateDefaultApp: (app: App | null) => Promise; + updateIgnorePatterns: (patterns: string[]) => Promise; + updateIncludeNested: (value: boolean) => Promise; updateRecentProjectsCount: (count: number) => Promise; + updateRequireMarkers: (value: boolean) => Promise; + updateScanDepth: (depth: number) => Promise; updateShowGitStatus: (show: boolean) => Promise; updateShowRecentProjects: (show: boolean) => Promise; + updateShowStashCount: (show: boolean) => Promise; + updateTag: (tagId: string, patch: Partial>) => Promise; updateTerminalApp: (app: App | null) => Promise; updateWorkspaceApps: (newWorkspaceApps: Record) => Promise; updateWorkspaces: (newWorkspaces: string[]) => Promise; + usageSnapshot: UsageSnapshot; workspaceApps: Record; workspaces: string[]; } @@ -62,10 +87,23 @@ function useWorkspaceStore(discover: boolean): UseWorkspaceReturn { const pref = usePreferences(); const ws = useWorkspaces(); const rp = useRecentProjects(); + const usage = useUsageStats(); + const tagStore = useTags(); + const scanOptions = useMemo( + () => ({ + ignorePatterns: pref.ignorePatterns, + includeNested: pref.includeNested, + requireMarkers: pref.requireMarkers, + scanDepth: pref.scanDepth, + }), + [pref.ignorePatterns, pref.includeNested, pref.requireMarkers, pref.scanDepth], + ); const pd = useProjectDiscovery( ws.workspaces, pref.showGitStatus, - discover && ws.isHydrated && pref.gitStatusHydrated, + discover && ws.isHydrated && pref.gitStatusHydrated && pref.discoveryHydrated, + scanOptions, + pref.showStashCount, discover, ); @@ -73,13 +111,21 @@ function useWorkspaceStore(discover: boolean): UseWorkspaceReturn { const snapshot = (): ExportedSettings => ({ defaultApp: pref.defaultApp, + ignorePatterns: pref.ignorePatterns, + includeNested: pref.includeNested, onboardingCompleted: pref.onboardingCompleted, pinnedProjects: rp.pinnedProjects, + projectTags: tagStore.projectTags, recentProjects: rp.recentProjects, recentProjectsCount: rp.recentProjectsCount, + requireMarkers: pref.requireMarkers, + scanDepth: pref.scanDepth, showGitStatus: pref.showGitStatus, showRecentProjects: pref.showRecentProjects, + showStashCount: pref.showStashCount, + tags: tagStore.tags, terminalApp: pref.terminalApp, + usage: usage.store, workspaceApps: ws.workspaceApps, workspaces: ws.workspaces, }); @@ -92,11 +138,20 @@ function useWorkspaceStore(discover: boolean): UseWorkspaceReturn { ws.updateWorkspaceApps(settings.workspaceApps), rp.updatePinnedProjects(settings.pinnedProjects), pref.updateShowGitStatus(settings.showGitStatus), + pref.updateShowStashCount(settings.showStashCount ?? false), pref.updateShowRecentProjects(settings.showRecentProjects), rp.updateRecentProjects(settings.recentProjects), rp.updateRecentProjectsCount(settings.recentProjectsCount), pref.setOnboardingCompleted(settings.onboardingCompleted), + pref.updateScanDepth(settings.scanDepth), + pref.updateIgnorePatterns(settings.ignorePatterns), + pref.updateIncludeNested(settings.includeNested), + pref.updateRequireMarkers(settings.requireMarkers), + tagStore.replaceTags(settings.tags ?? [], settings.projectTags ?? {}), ]); + if (settings.usage) { + await usage.replaceUsage(settings.usage); + } }; const importSettings = async (filePath: string): Promise => { @@ -117,11 +172,16 @@ function useWorkspaceStore(discover: boolean): UseWorkspaceReturn { const resetExtension = async (): Promise => { await applyImportedSettings(DEFAULT_SETTINGS); clearProjectsCache(); + await usage.clearUsage(); if (discover) { await pd.loadData(); } }; + const recordProjectOpen = async (projectPath: string): Promise => { + await Promise.all([usage.recordProjectOpen(projectPath), rp.recordProjectOpen(projectPath)]); + }; + useEffect(() => { if (!discover || !pd.hasScanned || !rp.isHydrated) { return; @@ -149,33 +209,54 @@ function useWorkspaceStore(discover: boolean): UseWorkspaceReturn { return { applyImportedSettings, + assignTag: tagStore.assignTag, + clearUsageStats: usage.clearUsage, + createTag: tagStore.createTag, defaultApp: pref.defaultApp, + deleteTag: tagStore.deleteTag, exportSettings: () => exportSettingsToDownloads(snapshot()), + getProjectTags: tagStore.getProjectTags, gitAvailable: gitAvailable ?? null, + ignorePatterns: pref.ignorePatterns, importSettings, + includeNested: pref.includeNested, isLoading: discover && pd.isLoading && pd.projects.length === 0, loadData: pd.loadData, onboardingCompleted: pref.onboardingCompleted, onboardingHydrated: pref.onboardingHydrated, pinnedProjects: rp.pinnedProjects, + projectTags: tagStore.projectTags, projects: pd.projects, recentProjects: rp.recentProjects, recentProjectsCount: rp.recentProjectsCount, - recordProjectOpen: rp.recordProjectOpen, + recordProjectOpen, + refreshProjectGit: pd.refreshProjectGit, reorderPinnedProject: rp.reorderPinnedProject, + requireMarkers: pref.requireMarkers, resetExtension, + scanDepth: pref.scanDepth, setOnboardingCompleted: pref.setOnboardingCompleted, showGitStatus: pref.showGitStatus, showRecentProjects: pref.showRecentProjects, + showStashCount: pref.showStashCount, + tags: tagStore.tags, terminalApp: pref.terminalApp, togglePinProject: rp.togglePinProject, + unassignTag: tagStore.unassignTag, updateDefaultApp: pref.updateDefaultApp, + updateIgnorePatterns: pref.updateIgnorePatterns, + updateIncludeNested: pref.updateIncludeNested, updateRecentProjectsCount: rp.updateRecentProjectsCount, + updateRequireMarkers: pref.updateRequireMarkers, + updateScanDepth: pref.updateScanDepth, updateShowGitStatus: pref.updateShowGitStatus, updateShowRecentProjects: pref.updateShowRecentProjects, + updateShowStashCount: pref.updateShowStashCount, + updateTag: tagStore.updateTag, updateTerminalApp: pref.updateTerminalApp, updateWorkspaceApps: ws.updateWorkspaceApps, updateWorkspaces: ws.updateWorkspaces, + usageSnapshot: usage.snapshot, workspaceApps: ws.workspaceApps, workspaces: ws.isHydrated || !discover ? ws.workspaces : pd.cachedWorkspaces, }; diff --git a/extensions/workspace/src/settings.tsx b/extensions/workspace/src/settings.tsx index 58f751236..22edcf1ce 100644 --- a/extensions/workspace/src/settings.tsx +++ b/extensions/workspace/src/settings.tsx @@ -3,7 +3,7 @@ import { WorkspaceProvider } from "@/hooks/useWorkspace"; export default function Command() { return ( - + ); diff --git a/extensions/workspace/src/types/index.ts b/extensions/workspace/src/types/index.ts index 4a016fccc..e0a29986d 100644 --- a/extensions/workspace/src/types/index.ts +++ b/extensions/workspace/src/types/index.ts @@ -6,17 +6,43 @@ export interface App { export interface ExportedSettings { defaultApp: App | null; + ignorePatterns: string[]; + includeNested: boolean; onboardingCompleted: boolean; pinnedProjects: string[]; + projectTags: Record; recentProjects: RecentProject[]; recentProjectsCount: number; + requireMarkers: boolean; + scanDepth: number; showGitStatus: boolean; showRecentProjects: boolean; + showStashCount: boolean; + tags: Tag[]; terminalApp: App | null; + usage?: UsageStore; workspaceApps: Record; workspaces: string[]; } +export interface Tag { + color: string; + id: string; + name: string; +} + + +export interface ProjectUsage { + days: Record; + lastOpened: number; + total: number; +} + +export interface UsageStore { + installedAt: number; + projects: Record; +} + export interface GitCommit { author: string; hash: string; @@ -26,15 +52,23 @@ export interface GitCommit { export interface GitStatus { branch: string; + /** Total dirty files (modified + untracked). */ dirty: number; + modified: number; pull: number; push: number; + stash: number; + untracked: number; + updatedAt: number; } export interface Project { fullPath: string; gitStatus?: GitStatus | null; + /** Absolute path to `.workspace/icon.*` when present. */ + iconPath?: string; isGitRepo?: boolean; + markers?: string[]; name: string; parentFolder: string; } @@ -47,5 +81,7 @@ export interface RecentProject { export interface SettingsBackup { exportedAt: string; settings: ExportedSettings; - version: 1; + version: 1 | 2 | 3 | 4; } + +export type GitResult = { ok: true } | { message: string; ok: false }; diff --git a/extensions/workspace/src/utils/constants.ts b/extensions/workspace/src/utils/constants.ts index aca909c67..a27d7ff77 100644 --- a/extensions/workspace/src/utils/constants.ts +++ b/extensions/workspace/src/utils/constants.ts @@ -7,10 +7,18 @@ export const STORAGE_KEY_APP = "workspace-default-app"; export const STORAGE_KEY_WORKSPACE_APPS = "workspace-workspace-app"; export const STORAGE_KEY_TERMINAL_APP = "workspace-terminal-app"; export const STORAGE_KEY_SHOW_GIT_STATUS = "workspace-show-git-status"; +export const STORAGE_KEY_SHOW_STASH_COUNT = "workspace-show-stash-count"; export const STORAGE_KEY_SHOW_RECENT_PROJECTS = "workspace-show-recent-projects"; export const STORAGE_KEY_RECENT_PROJECTS_COUNT = "workspace-recent-projects-count"; export const STORAGE_KEY_RECENT_PROJECTS = "workspace-recent-projects"; export const STORAGE_KEY_PROJECTS_CACHE = "workspace-projects-cache"; +export const STORAGE_KEY_SCAN_DEPTH = "workspace-scan-depth"; +export const STORAGE_KEY_IGNORE_PATTERNS = "workspace-ignore-patterns"; +export const STORAGE_KEY_INCLUDE_NESTED = "workspace-include-nested"; +export const STORAGE_KEY_REQUIRE_MARKERS = "workspace-require-markers"; +export const STORAGE_KEY_USAGE_STATS = "workspace-usage-stats"; +export const STORAGE_KEY_TAGS = "workspace-tags"; +export const STORAGE_KEY_PROJECT_TAGS = "workspace-project-tags"; export const DEFAULT_RECENT_PROJECTS_COUNT = 5; export const SHORTCUT_MOVE_UP: Keyboard.Shortcut.Common = "move-up"; diff --git a/extensions/workspace/src/utils/discovery.ts b/extensions/workspace/src/utils/discovery.ts new file mode 100644 index 000000000..45b6b3b78 --- /dev/null +++ b/extensions/workspace/src/utils/discovery.ts @@ -0,0 +1,208 @@ +import { access, readdir } from "fs/promises"; +import path from "path"; + +import { Project } from "@/types"; + +export const DEFAULT_SCAN_DEPTH = 1; +export const MAX_SCAN_DEPTH = 5; +export const MAX_DIRS_VISITED = 5000; + +export const DEFAULT_IGNORE_PATTERNS = [ + "node_modules", + "vendor", + "dist", + "build", + "target", + "Pods", + ".next", + ".turbo", + "coverage", + "__pycache__", + ".venv", + "venv", + "out", + "tmp", + "temp", +]; + +export const PROJECT_MARKERS = [ + ".git", + "package.json", + "Cargo.toml", + "go.mod", + "pyproject.toml", + "composer.json", + "Gemfile", + "mix.exs", + "pom.xml", + "build.gradle", + "build.gradle.kts", +] as const; + +/** Looked up under `/.workspace/` — first match wins. */ +export const PROJECT_ICON_FILES = [ + "icon.png", + "icon.jpg", + "icon.jpeg", + "icon.webp", + "icon.svg", + "icon.ico", +] as const; + +export type ScanOptions = { + ignorePatterns: string[]; + includeNested: boolean; + requireMarkers: boolean; + scanDepth: number; +}; + +export function clampScanDepth(value: number): number { + if (!Number.isFinite(value) || value < 1) { + return DEFAULT_SCAN_DEPTH; + } + return Math.min(Math.floor(value), MAX_SCAN_DEPTH); +} + +export function normalizeIgnorePatterns(patterns: unknown, fallback: string[]): string[] { + if (!Array.isArray(patterns)) { + return fallback; + } + + const cleaned = patterns + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean); + + return cleaned.length > 0 ? [...new Set(cleaned)] : fallback; +} + +export type WorkspaceScanResult = { + projects: Project[]; + truncated: boolean; +}; + +export async function scanWorkspaceProjects( + workspacePath: string, + options: ScanOptions, +): Promise { + const projects: Project[] = []; + let visited = 0; + let truncated = false; + const depth = clampScanDepth(options.scanDepth); + const ignore = normalizeIgnorePatterns(options.ignorePatterns, DEFAULT_IGNORE_PATTERNS); + + async function walk(dir: string, currentDepth: number): Promise { + if (truncated || currentDepth >= depth) { + return; + } + + if (visited >= MAX_DIRS_VISITED) { + truncated = true; + return; + } + + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + const directories = entries + .filter((entry) => entry.isDirectory() && !shouldSkipDirectory(entry.name, ignore)) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of directories) { + if (visited >= MAX_DIRS_VISITED) { + truncated = true; + return; + } + + visited += 1; + const fullPath = path.join(dir, entry.name); + const childDepth = currentDepth + 1; + const markers = await detectProjectMarkers(fullPath); + const treatAsProject = + markers.length > 0 || (childDepth === 1 && depth === 1 && !options.requireMarkers); + + if (treatAsProject) { + const iconPath = await detectProjectIcon(fullPath); + projects.push({ + fullPath, + iconPath, + markers: markers.length > 0 ? markers : undefined, + name: entry.name, + parentFolder: workspacePath, + }); + + if (!options.includeNested) { + continue; + } + } + + if (childDepth < depth) { + await walk(fullPath, childDepth); + } + } + } + + await walk(workspacePath, 0); + return { + projects: projects.sort((a, b) => a.name.localeCompare(b.name) || a.fullPath.localeCompare(b.fullPath)), + truncated, + }; +} + +export async function detectProjectMarkers(dirPath: string): Promise { + const found: string[] = []; + + for (const marker of PROJECT_MARKERS) { + if (await pathExists(path.join(dirPath, marker))) { + found.push(marker); + } + } + + return found; +} + +export async function detectProjectIcon(dirPath: string): Promise { + const folder = path.join(dirPath, ".workspace"); + for (const file of PROJECT_ICON_FILES) { + const candidate = path.join(folder, file); + if (await pathExists(candidate)) { + return candidate; + } + } + return undefined; +} + +function shouldSkipDirectory(name: string, ignorePatterns: string[]): boolean { + if (name.startsWith(".")) { + return true; + } + + return ignorePatterns.some((pattern) => matchesIgnorePattern(name, pattern)); +} + +function matchesIgnorePattern(name: string, pattern: string): boolean { + const normalized = pattern.replace(/^\.\//, "").replace(/\/$/, ""); + if (!normalized.includes("*") && !normalized.includes("/")) { + return name === normalized; + } + + if (normalized.includes("/")) { + return false; + } + + const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`, "i").test(name); +} + +async function pathExists(target: string): Promise { + try { + await access(target); + return true; + } catch { + return false; + } +} diff --git a/extensions/workspace/src/utils/git.ts b/extensions/workspace/src/utils/git.ts index 60b367f51..767e920b7 100644 --- a/extensions/workspace/src/utils/git.ts +++ b/extensions/workspace/src/utils/git.ts @@ -3,7 +3,7 @@ import { stat } from "fs/promises"; import path from "path"; import { promisify } from "util"; -import { GitCommit, GitStatus } from "@/types"; +import { GitCommit, GitResult, GitStatus } from "@/types"; const execFileAsync = promisify(execFile); @@ -27,14 +27,16 @@ export async function isGitAvailable(): Promise { return gitAvailableCache; } -export async function checkoutGitBranch(repoPath: string, branch: string): Promise { - if (!(await isGitAvailable())) return false; +export async function checkoutGitBranch(repoPath: string, branch: string): Promise { + if (!(await isGitAvailable())) { + return { message: "Git is not available", ok: false }; + } try { await execFileAsync("git", ["checkout", branch], { cwd: repoPath, timeout: GIT_TIMEOUT_MS }); - return true; - } catch { - return false; + return { ok: true }; + } catch (error) { + return { message: formatGitError(error, "Checkout failed"), ok: false }; } } @@ -65,16 +67,19 @@ export async function getCommitLog(repoPath: string): Promise { } } -export async function getGitStatus(repoPath: string): Promise { +export async function getGitStatus(repoPath: string, options: { includeStash?: boolean } = {}): Promise { if (!(await isGitAvailable()) || !(await isGitRepo(repoPath))) { return null; } try { - const { stdout } = await execFileAsync("git", ["-C", repoPath, "status", "-sb", "--porcelain=v1"], { - encoding: "utf8", - timeout: GIT_TIMEOUT_MS, - }); + const [{ stdout }, stash] = await Promise.all([ + execFileAsync("git", ["-C", repoPath, "status", "-sb", "--porcelain=v1"], { + encoding: "utf8", + timeout: GIT_TIMEOUT_MS, + }), + options.includeStash ? getStashCount(repoPath) : Promise.resolve(0), + ]); const lines = stdout.split("\n").filter((line) => line.length > 0); const header = lines[0]; @@ -88,11 +93,25 @@ export async function getGitStatus(repoPath: string): Promise return null; } + let modified = 0; + let untracked = 0; + for (const line of lines.slice(1)) { + if (line.startsWith("??")) { + untracked += 1; + } else { + modified += 1; + } + } + return { branch, - dirty: lines.length - 1, + dirty: modified + untracked, + modified, pull: Number(rest.match(/behind (\d+)/)?.[1] ?? 0), push: Number(rest.match(/ahead (\d+)/)?.[1] ?? 0), + stash, + untracked, + updatedAt: Date.now(), }; } catch { return null; @@ -133,14 +152,45 @@ export async function getRemoteUrl(repoPath: string): Promise { } } -export async function pullGitBranch(repoPath: string): Promise { - if (!(await isGitAvailable())) return false; +export async function getCloneUrl(repoPath: string): Promise { + if (!(await isGitAvailable())) return null; + + try { + const { stdout } = await execFileAsync("git", ["config", "--get", "remote.origin.url"], { + cwd: repoPath, + encoding: "utf8", + timeout: GIT_TIMEOUT_MS, + }); + const raw = stdout.trim(); + return raw || null; + } catch { + return null; + } +} + +export async function pullGitBranch(repoPath: string): Promise { + if (!(await isGitAvailable())) { + return { message: "Git is not available", ok: false }; + } try { await execFileAsync("git", ["pull"], { cwd: repoPath, timeout: GIT_PULL_TIMEOUT_MS }); - return true; + return { ok: true }; + } catch (error) { + return { message: formatGitError(error, "Pull failed"), ok: false }; + } +} + +async function getStashCount(repoPath: string): Promise { + try { + const { stdout } = await execFileAsync("git", ["-C", repoPath, "rev-list", "--walk-reflogs", "--count", "refs/stash"], { + encoding: "utf8", + timeout: GIT_TIMEOUT_MS, + }); + const count = Number(stdout.trim()); + return Number.isFinite(count) ? count : 0; } catch { - return false; + return 0; } } @@ -173,6 +223,57 @@ export function commitBrowserUrl(remoteUrl: string, hash: string): string { return `${remoteUrl}/commit/${hash}`; } +export function pullsBrowserUrl(remoteUrl: string): string { + try { + const host = new URL(remoteUrl).hostname.toLowerCase(); + if (host === "gitlab.com" || host.startsWith("gitlab.") || host.includes(".gitlab.")) { + return `${remoteUrl}/-/merge_requests`; + } + if (host === "bitbucket.org" || host.includes("bitbucket.")) { + return `${remoteUrl}/pull-requests`; + } + } catch { + // Fall through to the GitHub-style path. + } + + return `${remoteUrl}/pulls`; +} + +export function formatGitError(error: unknown, fallback: string): string { + const message = extractGitMessage(error); + if (!message) { + return fallback; + } + + if (/conflict|CONFLICT/i.test(message)) { + return "Merge conflicts: resolve them in the repo"; + } + if (/Authentication|Permission denied|could not read Username|Login|403|401/i.test(message)) { + return "Authentication failed: check credentials"; + } + if (/timed?\s*out|ETIMEDOUT|ESOCKETTIMEDOUT/i.test(message)) { + return "Timed out waiting for git"; + } + if (/local changes|uncommitted|would be overwritten|Please commit|stash/i.test(message)) { + return "Uncommitted changes block this action"; + } + if (/no tracking|no upstream|does not have.*upstream/i.test(message)) { + return "No upstream branch configured"; + } + + const compact = message.replace(/\s+/g, " ").trim(); + return compact.length > 140 ? `${compact.slice(0, 137)}…` : compact; +} + +function extractGitMessage(error: unknown): string { + if (!error || typeof error !== "object") { + return typeof error === "string" ? error : ""; + } + + const record = error as { message?: string; stderr?: string }; + return [record.stderr, record.message].filter(Boolean).join("\n"); +} + function normalizeRemoteUrl(url: string): null | string { if (!url) return null; diff --git a/extensions/workspace/src/utils/projectDisplay.ts b/extensions/workspace/src/utils/projectDisplay.ts new file mode 100644 index 000000000..98e8892a4 --- /dev/null +++ b/extensions/workspace/src/utils/projectDisplay.ts @@ -0,0 +1,125 @@ +import { Icon, Image, type ImageLike } from "@vicinae/api"; +import path from "path"; +import { pathToFileURL } from "url"; + +import { Project, Tag } from "@/types"; + +const MARKER_KEYWORDS: Record = { + ".git": ["git"], + "package.json": ["node", "javascript", "typescript", "npm"], + "Cargo.toml": ["rust", "cargo"], + "go.mod": ["go", "golang"], + "pyproject.toml": ["python"], + "composer.json": ["php", "composer"], + Gemfile: ["ruby", "rails"], + "mix.exs": ["elixir"], + "pom.xml": ["java", "maven"], + "build.gradle": ["java", "gradle", "android"], + "build.gradle.kts": ["java", "gradle", "kotlin"], +}; + +/** Marker-based fallback when no `.workspace/icon.*` is present. */ +export function markerIcon(markers?: string[]): Icon { + if (!markers?.length) { + return Icon.Folder; + } + + if (markers.includes("package.json")) return Icon.Code; + if (markers.includes("Cargo.toml")) return Icon.Hammer; + if (markers.includes("go.mod")) return Icon.CodeBlock; + if (markers.includes("pyproject.toml")) return Icon.BlankDocument; + if (markers.includes("composer.json") || markers.includes("Gemfile") || markers.includes("mix.exs")) { + return Icon.Code; + } + if (markers.includes("pom.xml") || markers.includes("build.gradle") || markers.includes("build.gradle.kts")) { + return Icon.Box; + } + if (markers.includes(".git")) return Icon.Folder; + + return Icon.Folder; +} + +export function projectListIcon(project: Pick): ImageLike { + const fallback = markerIcon(project.markers); + if (!project.iconPath) { + return fallback; + } + + return { + fallback, + mask: Image.Mask.RoundedRectangle, + source: pathToFileURL(project.iconPath).href, + }; +} + +export function projectKeywords(project: Project, showGitStatus: boolean, tags: Tag[] = []): string[] { + const keywords = new Set(); + keywords.add(project.fullPath); + keywords.add(project.parentFolder); + const workspaceName = path.basename(project.parentFolder); + keywords.add(workspaceName); + keywords.add(path.dirname(project.fullPath)); + keywords.add(`@${workspaceName}`); + + for (const marker of project.markers ?? []) { + keywords.add(marker); + for (const alias of MARKER_KEYWORDS[marker] ?? []) { + keywords.add(alias); + } + } + + for (const tag of tags) { + keywords.add(tag.name); + keywords.add(`tag:${tag.name}`); + } + + if (showGitStatus && project.gitStatus?.branch) { + keywords.add(project.gitStatus.branch); + } + + if (project.gitStatus) { + if (project.gitStatus.dirty > 0) keywords.add("dirty"); + if (project.gitStatus.dirty === 0) keywords.add("clean"); + if (project.gitStatus.untracked > 0) keywords.add("untracked"); + } + + return [...keywords]; +} + +export function formatGitUpdatedAt(updatedAt?: number): string | undefined { + if (!updatedAt) return undefined; + const seconds = Math.max(0, Math.floor((Date.now() - updatedAt) / 1000)); + if (seconds < 10) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export type ProjectFilter = "all" | "dirty" | "clean" | "pinned" | `ws:${string}` | `tag:${string}`; + +export function matchesProjectFilter( + project: Project, + filter: ProjectFilter, + pinnedPaths: Set, + projectTags: Record = {}, +): boolean { + if (filter === "all") return true; + if (filter === "pinned") return pinnedPaths.has(project.fullPath); + if (filter === "dirty") return (project.gitStatus?.dirty ?? 0) > 0; + if (filter === "clean") { + if (!project.isGitRepo && project.gitStatus == null) return false; + return (project.gitStatus?.dirty ?? 0) === 0; + } + if (filter.startsWith("ws:")) { + return project.parentFolder === filter.slice(3); + } + if (filter.startsWith("tag:")) { + const tagId = filter.slice(4); + return (projectTags[project.fullPath] ?? []).includes(tagId); + } + return true; +} diff --git a/extensions/workspace/src/utils/readme.ts b/extensions/workspace/src/utils/readme.ts new file mode 100644 index 000000000..a494a28b7 --- /dev/null +++ b/extensions/workspace/src/utils/readme.ts @@ -0,0 +1,31 @@ +import { access } from "fs/promises"; +import path from "path"; + +const README_CANDIDATES = [ + "README.md", + "README", + "Readme.md", + "readme.md", + "README.txt", + "README.rst", + "README.adoc", +]; + +export async function findReadmePath(projectPath: string): Promise { + for (const name of README_CANDIDATES) { + const candidate = path.join(projectPath, name); + if (await pathExists(candidate)) { + return candidate; + } + } + return null; +} + +async function pathExists(target: string): Promise { + try { + await access(target); + return true; + } catch { + return false; + } +} diff --git a/extensions/workspace/src/utils/storage.ts b/extensions/workspace/src/utils/storage.ts index 3da2eb038..d2d760f24 100644 --- a/extensions/workspace/src/utils/storage.ts +++ b/extensions/workspace/src/utils/storage.ts @@ -3,18 +3,33 @@ import { mkdir, readFile, writeFile } from "fs/promises"; import os from "os"; import path from "path"; -import { App, ExportedSettings, RecentProject, SettingsBackup } from "@/types"; +import { App, ExportedSettings, RecentProject, SettingsBackup, UsageStore } from "@/types"; import { DEFAULT_RECENT_PROJECTS_COUNT } from "@/utils/constants"; +import { + clampScanDepth, + DEFAULT_IGNORE_PATTERNS, + DEFAULT_SCAN_DEPTH, + normalizeIgnorePatterns, +} from "@/utils/discovery"; +import { normalizeProjectTags, normalizeTags } from "@/utils/tags"; +import { normalizeUsageStore } from "@/utils/usage"; import { normalizeApp } from "@/utils/validation"; export const DEFAULT_SETTINGS: ExportedSettings = { defaultApp: null, + ignorePatterns: DEFAULT_IGNORE_PATTERNS, + includeNested: false, onboardingCompleted: false, pinnedProjects: [], + projectTags: {}, recentProjects: [], recentProjectsCount: DEFAULT_RECENT_PROJECTS_COUNT, + requireMarkers: false, + scanDepth: DEFAULT_SCAN_DEPTH, showGitStatus: true, showRecentProjects: false, + showStashCount: false, + tags: [], terminalApp: null, workspaceApps: {}, workspaces: [], @@ -25,7 +40,7 @@ export async function exportSettingsToDownloads(settings: ExportedSettings): Pro const backup: SettingsBackup = { exportedAt: new Date().toISOString(), settings, - version: 1, + version: 4, }; const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const filename = `workspace-settings-${timestamp}.json`; @@ -75,8 +90,13 @@ export function normalizeImportedSettings(payload: unknown, fallback: ExportedSe ? (payload.settings as Partial) : (payload as Partial); + const tags = normalizeTags(parsedSettings.tags ?? fallback.tags); + return { defaultApp: normalizeApp(parsedSettings.defaultApp), + ignorePatterns: normalizeIgnorePatterns(parsedSettings.ignorePatterns, fallback.ignorePatterns), + includeNested: + typeof parsedSettings.includeNested === "boolean" ? parsedSettings.includeNested : fallback.includeNested, onboardingCompleted: typeof parsedSettings.onboardingCompleted === "boolean" ? parsedSettings.onboardingCompleted @@ -84,6 +104,7 @@ export function normalizeImportedSettings(payload: unknown, fallback: ExportedSe pinnedProjects: Array.isArray(parsedSettings.pinnedProjects) ? parsedSettings.pinnedProjects.filter((value): value is string => typeof value === "string") : fallback.pinnedProjects, + projectTags: normalizeProjectTags(parsedSettings.projectTags ?? fallback.projectTags, tags), recentProjects: Array.isArray(parsedSettings.recentProjects) ? parsedSettings.recentProjects.filter( (value): value is RecentProject => @@ -97,13 +118,21 @@ export function normalizeImportedSettings(payload: unknown, fallback: ExportedSe typeof parsedSettings.recentProjectsCount === "number" && parsedSettings.recentProjectsCount > 0 ? parsedSettings.recentProjectsCount : fallback.recentProjectsCount, + requireMarkers: + typeof parsedSettings.requireMarkers === "boolean" ? parsedSettings.requireMarkers : fallback.requireMarkers, + scanDepth: + typeof parsedSettings.scanDepth === "number" ? clampScanDepth(parsedSettings.scanDepth) : fallback.scanDepth, showGitStatus: typeof parsedSettings.showGitStatus === "boolean" ? parsedSettings.showGitStatus : fallback.showGitStatus, showRecentProjects: typeof parsedSettings.showRecentProjects === "boolean" ? parsedSettings.showRecentProjects : fallback.showRecentProjects, + showStashCount: + typeof parsedSettings.showStashCount === "boolean" ? parsedSettings.showStashCount : fallback.showStashCount, + tags, terminalApp: normalizeApp(parsedSettings.terminalApp), + usage: normalizeImportedUsage(parsedSettings.usage, fallback.usage), workspaceApps: parsedSettings.workspaceApps && typeof parsedSettings.workspaceApps === "object" ? Object.fromEntries( @@ -119,13 +148,27 @@ export function normalizeImportedSettings(payload: unknown, fallback: ExportedSe }; } +function normalizeImportedUsage( + value: UsageStore | undefined, + fallback: UsageStore | undefined, +): UsageStore | undefined { + if (value) { + return normalizeUsageStore(value); + } + return fallback ? normalizeUsageStore(fallback) : undefined; +} + function isRecognizableBackup(payload: unknown): boolean { if (!payload || typeof payload !== "object") { return false; } const record = payload as Record; - if (record.version === 1 && record.settings && typeof record.settings === "object") { + if ( + (record.version === 1 || record.version === 2 || record.version === 3 || record.version === 4) && + record.settings && + typeof record.settings === "object" + ) { return true; } diff --git a/extensions/workspace/src/utils/suggestions.ts b/extensions/workspace/src/utils/suggestions.ts new file mode 100644 index 000000000..70c85baaa --- /dev/null +++ b/extensions/workspace/src/utils/suggestions.ts @@ -0,0 +1,14 @@ +import os from "os"; +import path from "path"; + +import { isExistingDirectory } from "@/utils/paths"; + +const SUGGESTED_FOLDER_NAMES = ["Projects", "Developer", "code", "src", "repos", "work", "dev"]; + +export function suggestedWorkspacePaths(existing: string[] = []): string[] { + const home = os.homedir(); + const existingSet = new Set(existing); + const candidates = SUGGESTED_FOLDER_NAMES.map((name) => path.join(home, name)); + + return candidates.filter((candidate) => isExistingDirectory(candidate) && !existingSet.has(candidate)); +} diff --git a/extensions/workspace/src/utils/tags.ts b/extensions/workspace/src/utils/tags.ts new file mode 100644 index 000000000..57da77bf7 --- /dev/null +++ b/extensions/workspace/src/utils/tags.ts @@ -0,0 +1,157 @@ +import { Color } from "@vicinae/api"; + +import { Tag } from "@/types"; + +export const TAG_COLORS: { color: Color; label: string }[] = [ + { color: Color.Blue, label: "Blue" }, + { color: Color.Green, label: "Green" }, + { color: Color.Magenta, label: "Magenta" }, + { color: Color.Orange, label: "Orange" }, + { color: Color.Purple, label: "Purple" }, + { color: Color.Red, label: "Red" }, + { color: Color.Yellow, label: "Yellow" }, +]; + +export function createTagId(name: string): string { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + const suffix = Math.random().toString(36).slice(2, 8); + return slug ? `${slug}-${suffix}` : suffix; +} + +export function normalizeTags(value: unknown): Tag[] { + if (!Array.isArray(value)) { + return []; + } + + const seen = new Set(); + const tags: Tag[] = []; + + for (const entry of value) { + if (!entry || typeof entry !== "object") continue; + const record = entry as Partial; + if (typeof record.id !== "string" || !record.id.trim()) continue; + if (typeof record.name !== "string" || !record.name.trim()) continue; + if (seen.has(record.id)) continue; + seen.add(record.id); + tags.push({ + color: isTagColor(record.color) ? record.color : Color.Blue, + id: record.id, + name: record.name.trim(), + }); + } + + return tags.sort((a, b) => a.name.localeCompare(b.name)); +} + +export function normalizeProjectTags(value: unknown, tags: Tag[]): Record { + if (!value || typeof value !== "object") { + return {}; + } + + const validIds = new Set(tags.map((tag) => tag.id)); + const result: Record = {}; + + for (const [projectPath, ids] of Object.entries(value as Record)) { + if (typeof projectPath !== "string" || !projectPath) continue; + if (!Array.isArray(ids)) continue; + const cleaned = [...new Set(ids.filter((id): id is string => typeof id === "string" && validIds.has(id)))]; + if (cleaned.length > 0) { + result[projectPath] = cleaned; + } + } + + return result; +} + +export function tagsForProject(projectPath: string, tags: Tag[], projectTags: Record): Tag[] { + const ids = projectTags[projectPath] ?? []; + if (ids.length === 0) return []; + const byId = new Map(tags.map((tag) => [tag.id, tag])); + return ids.map((id) => byId.get(id)).filter((tag): tag is Tag => tag != null); +} + +export function addTagToProject( + projectTags: Record, + projectPath: string, + tagId: string, +): Record { + const current = projectTags[projectPath] ?? []; + if (current.includes(tagId)) { + return projectTags; + } + return { ...projectTags, [projectPath]: [...current, tagId] }; +} + +export function removeTagFromProject( + projectTags: Record, + projectPath: string, + tagId: string, +): Record { + const current = projectTags[projectPath] ?? []; + const next = current.filter((id) => id !== tagId); + const copy = { ...projectTags }; + if (next.length === 0) { + delete copy[projectPath]; + } else { + copy[projectPath] = next; + } + return copy; +} + +/** Replace every assignment of `tagId` with exactly `projectPaths`. */ +export function syncTagProjects( + projectTags: Record, + tagId: string, + projectPaths: string[], +): Record { + let next = { ...projectTags }; + + for (const path of Object.keys(next)) { + next = removeTagFromProject(next, path, tagId); + } + + for (const path of projectPaths) { + next = addTagToProject(next, path, tagId); + } + + return next; +} + +export function projectsWithTag(projectTags: Record, tagId: string): string[] { + return Object.entries(projectTags) + .filter(([, ids]) => ids.includes(tagId)) + .map(([path]) => path) + .sort(); +} + +export function deleteTagFromCatalog( + tags: Tag[], + projectTags: Record, + tagId: string, +): { projectTags: Record; tags: Tag[] } { + const nextTags = tags.filter((tag) => tag.id !== tagId); + const nextProjectTags: Record = {}; + for (const [projectPath, ids] of Object.entries(projectTags)) { + const cleaned = ids.filter((id) => id !== tagId); + if (cleaned.length > 0) { + nextProjectTags[projectPath] = cleaned; + } + } + return { projectTags: nextProjectTags, tags: nextTags }; +} + +function isTagColor(value: unknown): value is Color { + return ( + value === Color.Blue || + value === Color.Green || + value === Color.Magenta || + value === Color.Orange || + value === Color.Purple || + value === Color.Red || + value === Color.Yellow + ); +} diff --git a/extensions/workspace/src/utils/usage.ts b/extensions/workspace/src/utils/usage.ts new file mode 100644 index 000000000..397bb2ec4 --- /dev/null +++ b/extensions/workspace/src/utils/usage.ts @@ -0,0 +1,212 @@ +import { ProjectUsage, UsageStore } from "@/types"; + +export type UsagePeriod = "all" | "year" | "month" | "week" | "day"; + +export interface RankedProject { + count: number; + lastOpened: number; + path: string; +} + +export interface UsageSnapshot { + installedAt: number; + mostOpened: Record; + opensInPeriod: Record; + projectsTracked: number; + topProjects: Record; + totalOpens: number; +} + +export function createEmptyUsageStore(now = Date.now()): UsageStore { + return { installedAt: now, projects: {} }; +} + +export function normalizeUsageStore(value: unknown, now = Date.now()): UsageStore { + if (!value || typeof value !== "object") { + return createEmptyUsageStore(now); + } + + const record = value as Partial; + const installedAt = + typeof record.installedAt === "number" && Number.isFinite(record.installedAt) ? record.installedAt : now; + + const projects: Record = {}; + if (record.projects && typeof record.projects === "object") { + for (const [pathKey, entry] of Object.entries(record.projects)) { + const normalized = normalizeProjectUsage(entry); + if (normalized) { + projects[pathKey] = normalized; + } + } + } + + return { installedAt, projects }; +} + +export function recordOpen(store: UsageStore, projectPath: string, at = Date.now()): UsageStore { + const day = toDayKey(at); + const existing = store.projects[projectPath]; + const days = { ...(existing?.days ?? {}) }; + days[day] = (days[day] ?? 0) + 1; + + return { + ...store, + installedAt: store.installedAt || at, + projects: { + ...store.projects, + [projectPath]: { + days, + lastOpened: at, + total: (existing?.total ?? 0) + 1, + }, + }, + }; +} + +export function buildUsageSnapshot(store: UsageStore, now = Date.now(), topN = 25): UsageSnapshot { + const ranges = periodRanges(now); + const mostOpened = {} as Record; + const opensInPeriod = {} as Record; + const topProjects = {} as Record; + + for (const period of PERIODS) { + const ranked = rankProjects(store, ranges[period].start, topN); + topProjects[period] = ranked; + mostOpened[period] = ranked[0] ?? null; + opensInPeriod[period] = countOpensInRange(store, ranges[period].start); + } + + return { + installedAt: store.installedAt, + mostOpened, + opensInPeriod, + projectsTracked: Object.keys(store.projects).length, + topProjects, + totalOpens: Object.values(store.projects).reduce((sum, entry) => sum + entry.total, 0), + }; +} + +export function projectDisplayName(projectPath: string): string { + const parts = projectPath.replace(/\\/g, "/").split("/"); + return parts[parts.length - 1] || projectPath; +} + +export function formatInstalledSince(installedAt: number, now = Date.now()): string { + const days = Math.max(0, Math.floor((now - installedAt) / (24 * 60 * 60 * 1000))); + const date = new Date(installedAt).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); + + if (days === 0) { + return `Today (${date})`; + } + if (days === 1) { + return `1 day (${date})`; + } + return `${days} days (${date})`; +} + +const PERIODS: UsagePeriod[] = ["all", "year", "month", "week", "day"]; + +function normalizeProjectUsage(value: unknown): ProjectUsage | null { + if (!value || typeof value !== "object") { + return null; + } + + const entry = value as Partial; + const total = typeof entry.total === "number" && entry.total > 0 ? Math.floor(entry.total) : 0; + const lastOpened = + typeof entry.lastOpened === "number" && Number.isFinite(entry.lastOpened) ? entry.lastOpened : 0; + + const days: Record = {}; + if (entry.days && typeof entry.days === "object") { + for (const [day, count] of Object.entries(entry.days)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(day) && typeof count === "number" && count > 0) { + days[day] = Math.floor(count); + } + } + } + + if (total === 0 && Object.keys(days).length === 0) { + return null; + } + + const summed = Object.values(days).reduce((sum, count) => sum + count, 0); + return { + days, + lastOpened, + total: Math.max(total, summed), + }; +} + +function periodRanges(now: number): Record { + const date = new Date(now); + + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + + const startOfWeek = new Date(date); + const day = startOfWeek.getDay(); + const diffToMonday = day === 0 ? 6 : day - 1; + startOfWeek.setHours(0, 0, 0, 0); + startOfWeek.setDate(startOfWeek.getDate() - diffToMonday); + + const startOfMonth = new Date(date.getFullYear(), date.getMonth(), 1); + const startOfYear = new Date(date.getFullYear(), 0, 1); + + return { + all: { start: null }, + day: { start: startOfDay.getTime() }, + month: { start: startOfMonth.getTime() }, + week: { start: startOfWeek.getTime() }, + year: { start: startOfYear.getTime() }, + }; +} + +function rankProjects(store: UsageStore, since: number | null, topN: number): RankedProject[] { + const ranked: RankedProject[] = []; + + for (const [pathKey, entry] of Object.entries(store.projects)) { + const count = since == null ? entry.total : countProjectOpensSince(entry, since); + if (count <= 0) { + continue; + } + ranked.push({ count, lastOpened: entry.lastOpened, path: pathKey }); + } + + ranked.sort((a, b) => b.count - a.count || b.lastOpened - a.lastOpened || a.path.localeCompare(b.path)); + return ranked.slice(0, topN); +} + +function countOpensInRange(store: UsageStore, since: number | null): number { + let total = 0; + for (const entry of Object.values(store.projects)) { + total += since == null ? entry.total : countProjectOpensSince(entry, since); + } + return total; +} + +function countProjectOpensSince(entry: ProjectUsage, since: number): number { + let total = 0; + for (const [day, count] of Object.entries(entry.days)) { + if (dayKeyToTime(day) >= since) { + total += count; + } + } + return total; +} + +function toDayKey(timestamp: number): string { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function dayKeyToTime(day: string): number { + const [year, month, date] = day.split("-").map(Number); + return new Date(year, month - 1, date).getTime(); +} diff --git a/extensions/workspace/src/utils/version.ts b/extensions/workspace/src/utils/version.ts new file mode 100644 index 000000000..271fcd671 --- /dev/null +++ b/extensions/workspace/src/utils/version.ts @@ -0,0 +1,7 @@ +import manifest from "../../package.json"; + +/** Extension version from package.json, the single source of truth. */ +export const EXTENSION_VERSION: string = + typeof (manifest as { version?: unknown }).version === "string" + ? (manifest as { version: string }).version + : "-"; diff --git a/extensions/workspace/src/workspace.tsx b/extensions/workspace/src/workspace.tsx index 3c4f0600f..90f789406 100644 --- a/extensions/workspace/src/workspace.tsx +++ b/extensions/workspace/src/workspace.tsx @@ -1,6 +1,6 @@ import { Action, ActionPanel, Icon, List } from "@vicinae/api"; import path from "path"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import Onboarding from "@/components/Onboarding"; import ProjectItem from "@/components/ProjectItem"; @@ -8,6 +8,7 @@ import Settings from "@/components/Settings"; import { useWorkspace, WorkspaceProvider } from "@/hooks/useWorkspace"; import { Project } from "@/types"; import { listItemId } from "@/utils/paths"; +import { matchesProjectFilter, type ProjectFilter } from "@/utils/projectDisplay"; import { organizeProjects } from "@/utils/projects"; import { toApp } from "@/utils/validation"; @@ -28,20 +29,26 @@ function WorkspaceCommand() { onboardingCompleted, onboardingHydrated, pinnedProjects, + projectTags, projects, recentProjects, recentProjectsCount, recordProjectOpen, + refreshProjectGit, reorderPinnedProject, setOnboardingCompleted, showGitStatus, showRecentProjects, + showStashCount, + tags, terminalApp, togglePinProject, updateDefaultApp, workspaceApps, workspaces, } = useWorkspace(); + const [filter, setFilter] = useState("all"); + const { hasVisibleProjects, pinnedList, projectsByWorkspace, recentList } = useMemo( () => organizeProjects({ @@ -57,6 +64,22 @@ function WorkspaceCommand() { const pinnedSet = useMemo(() => new Set(pinnedProjects), [pinnedProjects]); + const filtered = useMemo(() => { + const keep = (project: Project) => matchesProjectFilter(project, filter, pinnedSet, projectTags); + return { + pinnedList: pinnedList.filter(keep), + projectsByWorkspace: Object.fromEntries( + Object.entries(projectsByWorkspace).map(([folder, items]) => [folder, items.filter(keep)]), + ) as Record, + recentList: recentList.filter(keep), + }; + }, [filter, pinnedList, pinnedSet, projectTags, projectsByWorkspace, recentList]); + + const filteredCount = + filtered.pinnedList.length + + filtered.recentList.length + + Object.values(filtered.projectsByWorkspace).reduce((sum, items) => sum + items.length, 0); + if (onboardingHydrated && !onboardingCompleted) { return ( - {pinnedList.length > 0 && {renderProjects(pinnedList, true)}} - {recentList.length > 0 && {renderProjects(recentList, false)}} + setFilter(value as ProjectFilter)} tooltip="Filter" value={filter}> + + + {tags.length > 0 ? ( + + {tags.map((tag) => ( + + ))} + + ) : null} + {workspaces.length > 0 ? ( + + {workspaces.map((folder) => ( + + ))} + + ) : null} + + + + + + } + searchBarPlaceholder="Search projects…" + > + {filtered.pinnedList.length > 0 && ( + {renderProjects(filtered.pinnedList, true)} + )} + {filtered.recentList.length > 0 && ( + {renderProjects(filtered.recentList, false)} + )} {workspaces.map((folder) => { - const workspaceProjects = projectsByWorkspace[folder] ?? []; + const workspaceProjects = filtered.projectsByWorkspace[folder] ?? []; if (workspaceProjects.length === 0) return null; return ( @@ -128,6 +189,13 @@ function WorkspaceCommand() { title="No Projects Found" /> )} + {workspaces.length > 0 && hasVisibleProjects && filter !== "all" && filteredCount === 0 && ( + + )} {workspaces.length > 0 && hasVisibleProjects && ( )} @@ -145,7 +213,12 @@ function RefreshAndSettingsActions({ loadData }: { loadData: () => Promise shortcut={{ key: "r", modifiers: ["cmd", "shift"] }} title="Refresh Projects" /> - } title="Open Settings" /> + } + title="Open Settings…" + />
); From 7fff00efe44699bef34f05a70ee7180966c1f1af Mon Sep 17 00:00:00 2001 From: Tiago Miguel Date: Wed, 23 Sep 2026 22:24:24 +0100 Subject: [PATCH 2/2] fix(workspace): address automated review findings Fail root workspace reads as incomplete scans so pins survive, queue pre-hydration writes instead of dropping them, and scope bulk select-all naming to the workspace filter. --- .../src/components/Settings/TagsSettings.tsx | 6 +++--- extensions/workspace/src/hooks/useCachedState.ts | 13 ++++++++++++- extensions/workspace/src/utils/discovery.ts | 3 +++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/extensions/workspace/src/components/Settings/TagsSettings.tsx b/extensions/workspace/src/components/Settings/TagsSettings.tsx index 33925fcee..f9026b2a6 100644 --- a/extensions/workspace/src/components/Settings/TagsSettings.tsx +++ b/extensions/workspace/src/components/Settings/TagsSettings.tsx @@ -308,14 +308,14 @@ function BulkAssignTagView({ tagId }: { tagId: string }) { actions={ - + } icon={{ source: Icon.Tag, tintColor: tag.color as Color }} subtitle={ workspaceRoot === "all" - ? "Toggle projects below, then save" + ? "Toggle projects below, then save · search does not limit selection" : `Only ${path.basename(workspaceRoot)} · toggle, then save` } title="Save Assignments" @@ -360,7 +360,7 @@ function BulkAssignTagView({ tagId }: { tagId: string }) { shortcut={{ key: "s", modifiers: ["cmd"] }} title="Save Assignments" /> - + } diff --git a/extensions/workspace/src/hooks/useCachedState.ts b/extensions/workspace/src/hooks/useCachedState.ts index 7a7e105b1..dbe832a6e 100644 --- a/extensions/workspace/src/hooks/useCachedState.ts +++ b/extensions/workspace/src/hooks/useCachedState.ts @@ -9,6 +9,7 @@ type Slot = { const slots = new Map(); const listeners = new Map void>>(); const loading = new Set(); +const pendingWrites = new Map(); function getOrCreateSlot(key: string, initialValue: unknown): Slot { let slot = slots.get(key); @@ -93,6 +94,14 @@ export function useCachedState(key: string, initialValue: T): [T, (value: T) return; } + if (pendingWrites.has(key)) { + const pending = pendingWrites.get(key) as T; + pendingWrites.delete(key); + write(key, { hydrated: true, value: pending }); + void LocalStorage.setItem(key, JSON.stringify(pending)); + return; + } + if (raw !== undefined && raw !== null) { try { const parsed = typeof raw === "string" ? (JSON.parse(raw) as T) : (raw as T); @@ -112,8 +121,10 @@ export function useCachedState(key: string, initialValue: T): [T, (value: T) const setCachedState = useCallback( (next: T) => { const current = getOrCreateSlot(key, initialValue); - // Writes before LocalStorage loads would persist defaults and skip hydration. if (!current.hydrated) { + // Queue the write; it is applied and persisted once hydration finishes. + pendingWrites.set(key, next); + write(key, { hydrated: false, value: next }); return; } diff --git a/extensions/workspace/src/utils/discovery.ts b/extensions/workspace/src/utils/discovery.ts index 45b6b3b78..03ad39508 100644 --- a/extensions/workspace/src/utils/discovery.ts +++ b/extensions/workspace/src/utils/discovery.ts @@ -105,6 +105,9 @@ export async function scanWorkspaceProjects( try { entries = await readdir(dir, { withFileTypes: true }); } catch { + if (currentDepth === 0) { + throw new Error(`Cannot read workspace folder: ${dir}`); + } return; }