diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 150b501901..cf2e4e8bfb 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -957,6 +957,81 @@ export class PostHogAPIClient { return all; } + // The desktop file system tree lives on its own server-controlled "desktop" + // surface, served from a route that is not in the generated OpenAPI client, + // so we use the raw fetcher and follow pagination manually. + async getDesktopFileSystem(): Promise { + const DESKTOP_FILE_SYSTEM_MAX_PAGES = 50; + const teamId = await this.getTeamId(); + const all: Schemas.FileSystem[] = []; + let urlPath: string = `/api/projects/${teamId}/desktop_file_system/`; + for (let i = 0; i < DESKTOP_FILE_SYSTEM_MAX_PAGES; i++) { + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch desktop file system: ${response.statusText}`, + ); + } + const page = (await response.json()) as Schemas.PaginatedFileSystemList; + all.push(...page.results); + if (!page.next) return all; + const nextUrl = new URL(page.next); + urlPath = `${nextUrl.pathname}${nextUrl.search}`; + } + log.warn( + `getDesktopFileSystem hit MAX_PAGES (${DESKTOP_FILE_SYSTEM_MAX_PAGES}); returning partial results`, + { returned: all.length }, + ); + return all; + } + + // Create a top-level channel (a folder row whose path is a single segment) on + // the desktop file system surface. Uses the raw fetcher for the same reason as + // getDesktopFileSystem: this route is not in the generated OpenAPI client. + async createDesktopFileSystemChannel( + name: string, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ path: name, type: "folder", depth: 1 }), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to create desktop file system channel: ${response.statusText}`, + ); + } + return (await response.json()) as Schemas.FileSystem; + } + + // Delete a desktop file system entry by id (used to remove top-level channels). + async deleteDesktopFileSystem(id: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(id)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path: urlPath, + }); + if (!response.ok && response.status !== 404) { + throw new Error( + `Failed to delete desktop file system channel: ${response.statusText}`, + ); + } + } + async getTask(taskId: string) { const teamId = await this.getTeamId(); const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, { diff --git a/apps/code/src/renderer/features/sidebar/components/ChannelsHeader.tsx b/apps/code/src/renderer/features/sidebar/components/ChannelsHeader.tsx new file mode 100644 index 0000000000..869e94d2c0 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/ChannelsHeader.tsx @@ -0,0 +1,31 @@ +import { Plus } from "@phosphor-icons/react"; +import { Flex, IconButton, Text } from "@radix-ui/themes"; +import { useState } from "react"; +import { CreateChannelModal } from "./CreateChannelModal"; + +// Header above the channel tree with an "add channel" affordance that opens a +// Slack-style create-channel modal. +export function ChannelsHeader() { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + + + + Channels + + setIsModalOpen(true)} + > + + + + + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/components/CreateChannelModal.tsx b/apps/code/src/renderer/features/sidebar/components/CreateChannelModal.tsx new file mode 100644 index 0000000000..8d4a8d4716 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/CreateChannelModal.tsx @@ -0,0 +1,118 @@ +import { Button } from "@components/ui/Button"; +import { Hash, X } from "@phosphor-icons/react"; +import { Dialog, Flex, IconButton, Text, TextField } from "@radix-ui/themes"; +import { useEffect, useState } from "react"; +import { useDesktopFileSystemMutations } from "../hooks/useDesktopFileSystem"; + +// Matches Slack's "Create a channel" naming constraint. +const MAX_CHANNEL_NAME_LENGTH = 80; + +interface CreateChannelModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateChannelModal({ + open, + onOpenChange, +}: CreateChannelModalProps) { + const { createChannel, isCreating } = useDesktopFileSystemMutations(); + const [name, setName] = useState(""); + + // Reset the field each time the modal opens so a previous draft never lingers. + useEffect(() => { + if (open) setName(""); + }, [open]); + + const trimmed = name.trim(); + const remaining = MAX_CHANNEL_NAME_LENGTH - name.length; + + const submit = async () => { + if (!trimmed) return; + try { + await createChannel(trimmed); + onOpenChange(false); + } catch { + // Keep the modal open so the user can retry; the mutation surfaces the error. + } + }; + + return ( + { + if (!isCreating) onOpenChange(next); + }} + > + + + + Create a channel + + + + + + + + + + + Name + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + > + + + + + + {remaining} + + + + + Channels are where conversations happen around a topic. Use a name + that is easy to find and understand. + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx b/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx new file mode 100644 index 0000000000..b92fe7e338 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx @@ -0,0 +1,156 @@ +import { Hash } from "@phosphor-icons/react"; +import { AlertDialog, Button, Flex, Spinner, Text } from "@radix-ui/themes"; +import { useState } from "react"; +import { useDesktopFileSystemMutations } from "../hooks/useDesktopFileSystem"; +import { useSidebarStore } from "../stores/sidebarStore"; +import type { FileSystemTreeNode } from "../utils/fileSystemTree"; +import { SidebarItem } from "./SidebarItem"; +import { SidebarSection } from "./SidebarSection"; + +// Persisted collapse state is shared with the task view's repo groups; namespace +// file system keys so the two never collide. +const collapseKey = (path: string) => `fs:${path}`; + +// Cap the visual indent so deeply nested paths don't push labels off-screen, +// while keeping the true depth available for keys. +const MAX_VISUAL_DEPTH = 6; + +interface FileSystemTreeNodeRowProps { + node: FileSystemTreeNode; + depth: number; + collapsedSections: Set; + toggleSection: (id: string) => void; + onDeleteChannel: (node: FileSystemTreeNode) => void; +} + +function FileSystemTreeNodeRow({ + node, + depth, + collapsedSections, + toggleSection, + onDeleteChannel, +}: FileSystemTreeNodeRowProps) { + const visualDepth = Math.min(depth, MAX_VISUAL_DEPTH); + + if (!node.isFolder) { + // Leaf rows are inert for now — a click hook is intentionally left unwired. + return ; + } + + const key = collapseKey(node.path); + const isExpanded = !collapsedSections.has(key); + // Only real top-level channel rows are deletable: depth 0 with a backing cloud + // row (derived intermediate folders have no item/id and can't be removed). + const isDeletableChannel = depth === 0 && Boolean(node.item?.id); + + return ( + } + depth={visualDepth} + isExpanded={isExpanded} + onToggle={() => toggleSection(key)} + addSpacingBefore={false} + tooltipContent={node.path} + onDelete={isDeletableChannel ? () => onDeleteChannel(node) : undefined} + deleteTooltip="Delete channel" + > + {node.children.map((child) => ( + + ))} + + ); +} + +export function FileSystemTreeView({ nodes }: { nodes: FileSystemTreeNode[] }) { + const collapsedSections = useSidebarStore((state) => state.collapsedSections); + const toggleSection = useSidebarStore((state) => state.toggleSection); + const { deleteChannel, isDeleting } = useDesktopFileSystemMutations(); + const [pendingDelete, setPendingDelete] = useState( + null, + ); + + const confirmDelete = async () => { + const id = pendingDelete?.item?.id; + if (!id) return; + try { + await deleteChannel(id); + } finally { + setPendingDelete(null); + } + }; + + if (nodes.length === 0) { + return ( + + No channels yet + + ); + } + + return ( + <> + + {nodes.map((node) => ( + + ))} + + + { + if (!open && !isDeleting) setPendingDelete(null); + }} + > + + + Delete channel "{pendingDelete?.name}"? + + + + This removes the channel for everyone in your project. This can't + be undone here. + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 89ff09e1c4..f737b0fbad 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -12,10 +12,12 @@ import { } from "@features/tasks/hooks/useArchiveTask"; import { useRenameTask, useTasks } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; import { ScrollArea, Separator } from "@posthog/quill"; import { Box, Flex } from "@radix-ui/themes"; import { trpcClient } from "@renderer/trpc/client"; +import { FILE_SYSTEM_SIDEBAR_FLAG } from "@shared/constants"; import type { Task } from "@shared/types"; import { useCommandMenuStore } from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; @@ -24,17 +26,22 @@ import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { useDesktopFileSystem } from "../hooks/useDesktopFileSystem"; import { usePinnedTasks } from "../hooks/usePinnedTasks"; import { useSidebarData } from "../hooks/useSidebarData"; import { useTaskViewed } from "../hooks/useTaskViewed"; import { useSidebarStore } from "../stores/sidebarStore"; import { useTaskSelectionStore } from "../stores/taskSelectionStore"; +import { buildFileSystemTree } from "../utils/fileSystemTree"; +import { ChannelsHeader } from "./ChannelsHeader"; +import { FileSystemTreeView } from "./FileSystemTreeView"; import { CommandCenterItem } from "./items/CommandCenterItem"; import { InboxItem, NewTaskItem } from "./items/HomeItem"; import { McpServersItem } from "./items/McpServersItem"; import { SearchItem } from "./items/SearchItem"; import { SkillsItem } from "./items/SkillsItem"; import { SidebarItem } from "./SidebarItem"; +import { SidebarPanelToggle } from "./SidebarPanelToggle"; import { TaskListView } from "./TaskListView"; const log = logger.scope("sidebar-menu"); @@ -68,6 +75,18 @@ function SidebarMenuComponent() { const sidebarData = useSidebarData({ activeView: view, }); + + const fsSidebarEnabled = + useFeatureFlag(FILE_SYSTEM_SIDEBAR_FLAG) || import.meta.env.DEV; + const activePanel = useSidebarStore((s) => s.activePanel); + const setActivePanel = useSidebarStore((s) => s.setActivePanel); + // The file-system tree is only ever shown behind the flag; without it we + // always fall back to the task list regardless of the persisted panel. + const showFiles = fsSidebarEnabled && activePanel === "files"; + const { data: fsItems = [], isLoading: fsLoading } = useDesktopFileSystem({ + enabled: showFiles, + }); + const fsTree = useMemo(() => buildFileSystemTree(fsItems), [fsItems]); const inboxPollingActive = useRendererWindowFocusStore((s) => s.focused); const { data: inboxProbe } = useInboxReports( { status: INBOX_PIPELINE_STATUS_FILTER }, @@ -406,7 +425,32 @@ function SidebarMenuComponent() { - {sidebarData.isLoading ? ( + {fsSidebarEnabled && ( + + + + )} + + {showFiles ? ( + <> + + {fsLoading ? ( + + } + label="Loading..." + disabled + /> + ) : ( + + )} + + ) : sidebarData.isLoading ? ( } diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarPanelToggle.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarPanelToggle.tsx new file mode 100644 index 0000000000..e95b8dbedd --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/SidebarPanelToggle.tsx @@ -0,0 +1,51 @@ +import { Hash, ListChecks } from "@phosphor-icons/react"; +import { Button, cn } from "@posthog/quill"; +import { Flex } from "@radix-ui/themes"; + +type SidebarPanel = "tasks" | "files"; + +// The "files" value is persisted as activePanel in the sidebar store; keep it as +// "files" so existing persisted state isn't orphaned. Only the visible label is +// "Channels". +const PANELS: { value: SidebarPanel; label: string; icon: React.ReactNode }[] = + [ + { value: "files", label: "Channels", icon: }, + { value: "tasks", label: "Tasks", icon: }, + ]; + +interface SidebarPanelToggleProps { + activePanel: SidebarPanel; + onChange: (panel: SidebarPanel) => void; +} + +// Segmented control letting the user switch between the file-system tree and +// their task list when the file-system sidebar flag is on. +export function SidebarPanelToggle({ + activePanel, + onChange, +}: SidebarPanelToggleProps) { + return ( + + {PANELS.map(({ value, label, icon }) => { + const isActive = activePanel === value; + return ( + + ); + })} + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx index c0efbb7292..a4ce8fd321 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx @@ -1,9 +1,18 @@ import { Tooltip } from "@components/ui/Tooltip"; -import { CaretDownIcon, CaretRightIcon, Plus } from "@phosphor-icons/react"; +import { + CaretDownIcon, + CaretRightIcon, + Plus, + Trash, +} from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; +// Mirrors SidebarItem's indent math so folder headers and leaf rows align at +// each nesting level. +const INDENT_SIZE = 8; + interface SidebarSectionProps { id: string; label: string; @@ -12,10 +21,13 @@ interface SidebarSectionProps { onToggle: () => void; children: React.ReactNode; addSpacingBefore?: boolean; + depth?: number; onContextMenu?: (e: React.MouseEvent) => void; tooltipContent?: string; onNewTask?: () => void; newTaskTooltip?: string; + onDelete?: () => void; + deleteTooltip?: string; dragHandleRef?: React.RefCallback; } @@ -26,10 +38,13 @@ export function SidebarSection({ onToggle, children, addSpacingBefore, + depth = 0, onContextMenu, tooltipContent, onNewTask, newTaskTooltip, + onDelete, + deleteTooltip, dragHandleRef, }: SidebarSectionProps) { const [isHovered, setIsHovered] = useState(false); @@ -40,8 +55,9 @@ export function SidebarSection({ diff --git a/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts b/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts new file mode 100644 index 0000000000..d9c778ab46 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts @@ -0,0 +1,66 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; +import type { Schemas } from "@renderer/api/generated"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +const DESKTOP_FILE_SYSTEM_POLL_INTERVAL_MS = 30_000; +const DESKTOP_FILE_SYSTEM_QUERY_KEY = ["desktop-file-system"] as const; + +export function useDesktopFileSystem(options?: { enabled?: boolean }) { + return useAuthenticatedQuery( + DESKTOP_FILE_SYSTEM_QUERY_KEY, + (client) => client.getDesktopFileSystem(), + { + enabled: options?.enabled ?? true, + refetchInterval: DESKTOP_FILE_SYSTEM_POLL_INTERVAL_MS, + }, + ); +} + +// Create/delete top-level channels on the desktop file system surface. Both +// mutations invalidate the shared query key so the tree refetches immediately +// rather than waiting on the 30s poll. +export function useDesktopFileSystemMutations() { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + + const invalidate = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: DESKTOP_FILE_SYSTEM_QUERY_KEY, + }); + }, [queryClient]); + + const createMutation = useMutation({ + mutationFn: async (name: string) => { + if (!client) throw new Error("Not authenticated"); + return client.createDesktopFileSystemChannel(name); + }, + onSuccess: invalidate, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + if (!client) throw new Error("Not authenticated"); + return client.deleteDesktopFileSystem(id); + }, + onSuccess: invalidate, + }); + + const createChannel = useCallback( + (name: string) => createMutation.mutateAsync(name), + [createMutation], + ); + + const deleteChannel = useCallback( + (id: string) => deleteMutation.mutateAsync(id), + [deleteMutation], + ); + + return { + createChannel, + deleteChannel, + isCreating: createMutation.isPending, + isDeleting: deleteMutation.isPending, + }; +} diff --git a/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts b/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts index b87d80c2f5..994ab86718 100644 --- a/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts +++ b/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts @@ -14,6 +14,7 @@ interface SidebarStoreState { sortMode: "updated" | "created"; showAllUsers: boolean; showInternal: boolean; + activePanel: "tasks" | "files"; } interface SidebarStoreActions { @@ -32,6 +33,7 @@ interface SidebarStoreActions { setSortMode: (mode: SidebarStoreState["sortMode"]) => void; setShowAllUsers: (showAllUsers: boolean) => void; setShowInternal: (showInternal: boolean) => void; + setActivePanel: (panel: SidebarStoreState["activePanel"]) => void; } type SidebarStore = SidebarStoreState & SidebarStoreActions; @@ -50,6 +52,7 @@ export const useSidebarStore = create()( sortMode: "updated", showAllUsers: false, showInternal: false, + activePanel: "files", setOpen: (open) => set({ open, hasUserSetOpen: true }), setOpenAuto: (open) => set((state) => (state.hasUserSetOpen ? state : { open })), @@ -100,6 +103,7 @@ export const useSidebarStore = create()( setSortMode: (sortMode) => set({ sortMode }), setShowAllUsers: (showAllUsers) => set({ showAllUsers }), setShowInternal: (showInternal) => set({ showInternal }), + setActivePanel: (activePanel) => set({ activePanel }), }), { name: "sidebar-storage", @@ -114,6 +118,7 @@ export const useSidebarStore = create()( sortMode: state.sortMode, showAllUsers: state.showAllUsers, showInternal: state.showInternal, + activePanel: state.activePanel, }), merge: (persisted, current) => { const persistedState = persisted as { @@ -127,6 +132,7 @@ export const useSidebarStore = create()( sortMode?: SidebarStoreState["sortMode"]; showAllUsers?: boolean; showInternal?: boolean; + activePanel?: SidebarStoreState["activePanel"]; }; return { ...current, @@ -145,6 +151,7 @@ export const useSidebarStore = create()( sortMode: persistedState.sortMode ?? current.sortMode, showAllUsers: persistedState.showAllUsers ?? current.showAllUsers, showInternal: persistedState.showInternal ?? current.showInternal, + activePanel: persistedState.activePanel ?? current.activePanel, }; }, }, diff --git a/apps/code/src/renderer/features/sidebar/utils/fileSystemTree.test.ts b/apps/code/src/renderer/features/sidebar/utils/fileSystemTree.test.ts new file mode 100644 index 0000000000..4b0b82799e --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/utils/fileSystemTree.test.ts @@ -0,0 +1,101 @@ +import type { Schemas } from "@renderer/api/generated"; +import { describe, expect, it } from "vitest"; +import { buildFileSystemTree } from "./fileSystemTree"; + +function item(partial: Partial): Schemas.FileSystem { + return { + id: partial.path ?? "id", + path: "", + depth: null, + created_at: "2026-01-01T00:00:00Z", + last_viewed_at: null, + ...partial, + }; +} + +describe("buildFileSystemTree", () => { + it("returns an empty array for no items", () => { + expect(buildFileSystemTree([])).toEqual([]); + }); + + it("derives intermediate folder nodes from a leaf path", () => { + const tree = buildFileSystemTree([ + item({ path: "Insights/Web/My insight", type: "insight", href: "/x" }), + ]); + + expect(tree).toHaveLength(1); + const insights = tree[0]; + expect(insights.name).toBe("Insights"); + expect(insights.isFolder).toBe(true); + expect(insights.item).toBeUndefined(); // derived, no explicit row + + const web = insights.children[0]; + expect(web.name).toBe("Web"); + expect(web.isFolder).toBe(true); + + const leaf = web.children[0]; + expect(leaf.name).toBe("My insight"); + expect(leaf.isFolder).toBe(false); + expect(leaf.item?.href).toBe("/x"); + }); + + it("attaches explicit folder rows to their node", () => { + const tree = buildFileSystemTree([ + item({ path: "Reports", type: "folder" }), + item({ path: "Reports/Sales", type: "dashboard", href: "/d" }), + ]); + + expect(tree).toHaveLength(1); + const reports = tree[0]; + expect(reports.isFolder).toBe(true); + expect(reports.item?.type).toBe("folder"); + expect(reports.children.map((c) => c.name)).toEqual(["Sales"]); + }); + + it("sorts folders first, then alphabetically", () => { + const tree = buildFileSystemTree([ + item({ path: "zeta", type: "insight", href: "/z" }), + item({ path: "alpha", type: "insight", href: "/a" }), + item({ path: "Mango", type: "folder" }), + item({ path: "Apple", type: "folder" }), + ]); + + expect(tree.map((n) => n.name)).toEqual([ + "Apple", + "Mango", + "alpha", + "zeta", + ]); + }); + + it("guards against empty path segments", () => { + const tree = buildFileSystemTree([ + item({ path: "/Leading/slash", type: "insight", href: "/l" }), + item({ path: "Trailing/slash/", type: "folder" }), + ]); + + const leading = tree.find((n) => n.name === "Leading"); + expect(leading).toBeDefined(); + expect(leading?.children[0].name).toBe("slash"); + // No blank-named nodes anywhere. + const names = new Set(); + const walk = (nodes: ReturnType) => { + for (const n of nodes) { + names.add(n.name); + walk(n.children); + } + }; + walk(tree); + expect(names.has("")).toBe(false); + }); + + it("keeps folder semantics when a leaf collides with a folder path", () => { + const tree = buildFileSystemTree([ + item({ path: "Shared", type: "folder" }), + item({ path: "Shared", type: "insight", href: "/s" }), + ]); + + expect(tree).toHaveLength(1); + expect(tree[0].isFolder).toBe(true); + }); +}); diff --git a/apps/code/src/renderer/features/sidebar/utils/fileSystemTree.ts b/apps/code/src/renderer/features/sidebar/utils/fileSystemTree.ts new file mode 100644 index 0000000000..036e374df4 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/utils/fileSystemTree.ts @@ -0,0 +1,106 @@ +import type { Schemas } from "@renderer/api/generated"; + +const FOLDER_TYPE = "folder"; + +export interface FileSystemTreeNode { + /** Stable id/key: the full slash-delimited path. Unique within the tree. */ + id: string; + /** Display label = last path segment. */ + name: string; + /** Full path, e.g. "Insights/Web/My insight". */ + path: string; + isFolder: boolean; + /** The originating flat row, if any (absent for derived intermediate folders). */ + item?: Schemas.FileSystem; + children: FileSystemTreeNode[]; +} + +/** + * Turn the flat list returned by the desktop file system endpoint into a nested + * tree. Paths are slash-delimited. Folders may be explicit rows + * (`type === "folder"`) or only implied by a leaf's path; intermediate folder + * nodes are created on demand. Children are sorted folders-first, then + * alphabetically. + */ +export function buildFileSystemTree( + items: Schemas.FileSystem[], +): FileSystemTreeNode[] { + const root: FileSystemTreeNode = { + id: "", + name: "", + path: "", + isFolder: true, + children: [], + }; + const byPath = new Map(); + byPath.set("", root); + + // Split on "/" and drop empty segments produced by leading/trailing/double + // slashes so we never create blank-named nodes. + const segmentsOf = (path: string): string[] => + path.split("/").filter((segment) => segment.length > 0); + + // Ensure a folder node exists for the given segment list, creating ancestors + // as needed. An empty list resolves to the root. + function ensureFolder(segments: string[]): FileSystemTreeNode { + const path = segments.join("/"); + const existing = byPath.get(path); + if (existing) return existing; + const parent = ensureFolder(segments.slice(0, -1)); + const node: FileSystemTreeNode = { + id: path, + name: segments[segments.length - 1] ?? path, + path, + isFolder: true, + children: [], + }; + byPath.set(path, node); + parent.children.push(node); + return node; + } + + for (const item of items) { + const segments = segmentsOf(item.path); + if (segments.length === 0) continue; + const name = segments[segments.length - 1]; + const normalizedPath = segments.join("/"); + + if (item.type === FOLDER_TYPE) { + const node = ensureFolder(segments); + node.item = item; + node.name = name; + continue; + } + + // Leaf. If a folder already claims this exact path, keep folder semantics + // and just attach the source row. + const existing = byPath.get(normalizedPath); + if (existing?.isFolder) { + existing.item = existing.item ?? item; + continue; + } + + const parent = ensureFolder(segments.slice(0, -1)); + const leaf: FileSystemTreeNode = { + id: normalizedPath, + name, + path: normalizedPath, + isFolder: false, + item, + children: [], + }; + byPath.set(normalizedPath, leaf); + parent.children.push(leaf); + } + + sortChildren(root); + return root.children; +} + +function sortChildren(node: FileSystemTreeNode): void { + node.children.sort((a, b) => { + if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const child of node.children) sortChildren(child); +} diff --git a/apps/code/src/shared/constants.ts b/apps/code/src/shared/constants.ts index 73b6097d8f..47b0246b99 100644 --- a/apps/code/src/shared/constants.ts +++ b/apps/code/src/shared/constants.ts @@ -3,6 +3,7 @@ export const INBOX_GATED_DUE_TO_SCALE_FLAG = "inbox-gated-due-to-scale"; export const EXPERIMENT_SUGGESTIONS_FLAG = "posthog-code-experiment-suggestions"; export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; +export const FILE_SYSTEM_SIDEBAR_FLAG = "posthog-code-file-system-sidebar"; export const BRANCH_PREFIX = "posthog-code/"; export const DATA_DIR = ".posthog-code"; export const WORKTREES_DIR = ".posthog-code/worktrees";