diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ca85bffa36e..efad6686b9c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -162,6 +162,7 @@ "tree-kill": "^1.2.2", "trpc-electron": "^0.1.2", "tw-animate-css": "^1.4.0", + "use-resize-observer": "^9.1.0", "zod": "^4.3.5", "zustand": "^5.0.8" }, diff --git a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts new file mode 100644 index 00000000000..fae69644f55 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts @@ -0,0 +1,286 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { shell } from "electron"; +import type { DirectoryEntry } from "shared/file-tree-types"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +export const createFilesystemRouter = () => { + return router({ + readDirectory: publicProcedure + .input( + z.object({ + dirPath: z.string(), + rootPath: z.string(), + includeHidden: z.boolean().default(false), + }), + ) + .query(async ({ input }): Promise => { + const { dirPath, rootPath, includeHidden } = input; + + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + return entries + .filter((entry) => includeHidden || !entry.name.startsWith(".")) + .map((entry) => { + const fullPath = path.join(dirPath, entry.name); + const relativePath = path.relative(rootPath, fullPath); + return { + id: relativePath, + name: entry.name, + path: fullPath, + relativePath, + isDirectory: entry.isDirectory(), + }; + }) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + } catch (error) { + console.error("[filesystem/readDirectory] Failed:", { + dirPath, + error, + }); + return []; + } + }), + + createFile: publicProcedure + .input( + z.object({ + dirPath: z.string(), + fileName: z.string(), + content: z.string().default(""), + }), + ) + .mutation(async ({ input }) => { + const filePath = path.join(input.dirPath, input.fileName); + + try { + await fs.access(filePath); + throw new Error(`File already exists: ${input.fileName}`); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("already exists") + ) { + throw error; + } + } + + await fs.writeFile(filePath, input.content, "utf-8"); + return { path: filePath }; + }), + + createDirectory: publicProcedure + .input( + z.object({ + parentPath: z.string(), + dirName: z.string(), + }), + ) + .mutation(async ({ input }) => { + const dirPath = path.join(input.parentPath, input.dirName); + + try { + await fs.access(dirPath); + throw new Error(`Directory already exists: ${input.dirName}`); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("already exists") + ) { + throw error; + } + } + + await fs.mkdir(dirPath, { recursive: true }); + return { path: dirPath }; + }), + + rename: publicProcedure + .input( + z.object({ + oldPath: z.string(), + newName: z.string(), + }), + ) + .mutation(async ({ input }) => { + const newPath = path.join(path.dirname(input.oldPath), input.newName); + + try { + await fs.access(newPath); + throw new Error(`Target already exists: ${input.newName}`); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("already exists") + ) { + throw error; + } + } + + await fs.rename(input.oldPath, newPath); + return { oldPath: input.oldPath, newPath }; + }), + + delete: publicProcedure + .input( + z.object({ + paths: z.array(z.string()), + permanent: z.boolean().default(false), + }), + ) + .mutation(async ({ input }) => { + const deleted: string[] = []; + const errors: { path: string; error: string }[] = []; + + for (const filePath of input.paths) { + try { + if (input.permanent) { + await fs.rm(filePath, { recursive: true, force: true }); + } else { + await shell.trashItem(filePath); + } + deleted.push(filePath); + } catch (error) { + errors.push({ + path: filePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { deleted, errors }; + }), + + move: publicProcedure + .input( + z.object({ + sourcePaths: z.array(z.string()), + destinationDir: z.string(), + }), + ) + .mutation(async ({ input }) => { + const moved: { from: string; to: string }[] = []; + const errors: { path: string; error: string }[] = []; + + for (const sourcePath of input.sourcePaths) { + try { + const fileName = path.basename(sourcePath); + const destPath = path.join(input.destinationDir, fileName); + + try { + await fs.access(destPath); + throw new Error(`Target already exists: ${fileName}`); + } catch (accessError) { + if ( + accessError instanceof Error && + accessError.message.includes("already exists") + ) { + throw accessError; + } + } + + await fs.rename(sourcePath, destPath); + moved.push({ from: sourcePath, to: destPath }); + } catch (error) { + errors.push({ + path: sourcePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { moved, errors }; + }), + + copy: publicProcedure + .input( + z.object({ + sourcePaths: z.array(z.string()), + destinationDir: z.string(), + }), + ) + .mutation(async ({ input }) => { + const copied: { from: string; to: string }[] = []; + const errors: { path: string; error: string }[] = []; + + for (const sourcePath of input.sourcePaths) { + try { + const fileName = path.basename(sourcePath); + let destPath = path.join(input.destinationDir, fileName); + + let counter = 1; + while (true) { + try { + await fs.access(destPath); + const ext = path.extname(fileName); + const base = path.basename(fileName, ext); + destPath = path.join( + input.destinationDir, + `${base} (${counter})${ext}`, + ); + counter++; + } catch { + break; + } + } + + await fs.cp(sourcePath, destPath, { recursive: true }); + copied.push({ from: sourcePath, to: destPath }); + } catch (error) { + errors.push({ + path: sourcePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { copied, errors }; + }), + + exists: publicProcedure + .input(z.object({ path: z.string() })) + .query(async ({ input }) => { + try { + await fs.access(input.path); + const stats = await fs.stat(input.path); + return { + exists: true, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + }; + } catch { + return { exists: false, isDirectory: false, isFile: false }; + } + }), + + stat: publicProcedure + .input(z.object({ path: z.string() })) + .query(async ({ input }) => { + try { + const stats = await fs.stat(input.path); + return { + size: stats.size, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + isSymbolicLink: stats.isSymbolicLink(), + createdAt: stats.birthtime.toISOString(), + modifiedAt: stats.mtime.toISOString(), + accessedAt: stats.atime.toISOString(), + }; + } catch (error) { + console.error("[filesystem/stat] Failed:", { + path: input.path, + error, + }); + return null; + } + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 2545a0f404b..ca340bddd4a 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -7,6 +7,7 @@ import { createCacheRouter } from "./cache"; import { createChangesRouter } from "./changes"; import { createConfigRouter } from "./config"; import { createExternalRouter } from "./external"; +import { createFilesystemRouter } from "./filesystem"; import { createHotkeysRouter } from "./hotkeys"; import { createMenuRouter } from "./menu"; import { createNotificationsRouter } from "./notifications"; @@ -30,6 +31,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { workspaces: createWorkspacesRouter(), terminal: createTerminalRouter(), changes: createChangesRouter(), + filesystem: createFilesystemRouter(), notifications: createNotificationsRouter(), ports: createPortsRouter(), menu: createMenuRouter(), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx index bd05016e592..4493fd939ec 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx @@ -8,7 +8,7 @@ import type { ChangeCategory, ChangedFile } from "shared/changes-types"; import { getStatusColor, getStatusIndicator, -} from "../../../Sidebar/ChangesView/utils"; +} from "../../../RightSidebar/ChangesView/utils"; import { createFileKey, useScrollContext } from "../../context"; import { DiffViewer } from "../DiffViewer"; import { FileDiffHeader } from "./components/FileDiffHeader"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/CategorySection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CategorySection/CategorySection.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/CategorySection.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CategorySection/CategorySection.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CategorySection/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CategorySection/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CategorySection/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx similarity index 81% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index edbc4deb96e..378ea736fa5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -10,20 +10,12 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useEffect, useRef, useState } from "react"; import { HiArrowPath, HiCheck } from "react-icons/hi2"; -import { - LuExpand, - LuGitBranch, - LuLoaderCircle, - LuShrink, - LuX, -} from "react-icons/lu"; +import { LuGitBranch, LuLoaderCircle } from "react-icons/lu"; import { VscGitStash, VscGitStashApply } from "react-icons/vsc"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { PRIcon } from "renderer/screens/main/components/PRIcon"; import { usePRStatus } from "renderer/screens/main/hooks"; import { useChangesStore } from "renderer/stores/changes"; -import { SidebarMode, useSidebarStore } from "renderer/stores/sidebar-state"; import type { ChangesViewMode } from "../../types"; import { ViewModeToggle } from "../ViewModeToggle"; @@ -235,13 +227,6 @@ export function ChangesHeader({ onStashPop, isStashPending, }: ChangesHeaderProps) { - const { toggleSidebar, currentMode, setMode } = useSidebarStore(); - const isExpanded = currentMode === SidebarMode.Changes; - - const handleExpandToggle = () => { - setMode(isExpanded ? SidebarMode.Tabs : SidebarMode.Changes); - }; - return (
@@ -254,47 +239,6 @@ export function ChangesHeader({ -
- - - - - - - - - - - - - - - -
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ChangesHeader/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CollapsibleRow/CollapsibleRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CollapsibleRow/CollapsibleRow.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CollapsibleRow/CollapsibleRow.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CollapsibleRow/CollapsibleRow.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CollapsibleRow/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CollapsibleRow/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CollapsibleRow/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CollapsibleRow/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitInput/CommitInput.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx similarity index 96% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitInput/CommitInput.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx index 811531f7c32..0b254dbc65a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/CommitInput/CommitInput.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx @@ -33,8 +33,6 @@ interface CommitInputProps { onRefresh: () => void; } -type GitAction = "commit" | "push" | "pull" | "sync"; - export function CommitInput({ worktreePath, hasStagedChanges, @@ -161,14 +159,7 @@ export function CommitInput({ }; // Determine primary action based on state - const getPrimaryAction = (): { - action: GitAction; - label: string; - icon: React.ReactNode; - handler: () => void; - disabled: boolean; - tooltip: string; - } => { + const getPrimaryAction = () => { if (canCommit) { return { action: "commit", @@ -209,7 +200,6 @@ export function CommitInput({ tooltip: `Pull ${pullCount} commit${pullCount !== 1 ? "s" : ""}`, }; } - // No upstream - show Publish Branch option if (!hasUpstream) { return { action: "push", @@ -232,7 +222,6 @@ export function CommitInput({ const primary = getPrimaryAction(); - // Format count badge const countBadge = pushCount > 0 || pullCount > 0 ? `${pullCount > 0 ? pullCount : ""}${pullCount > 0 && pushCount > 0 ? "/" : ""}${pushCount > 0 ? pushCount : ""}` @@ -283,7 +272,6 @@ export function CommitInput({ - {/* Commit actions */} { - // Clear any pending single-click timeout if (clickTimeoutRef.current) { clearTimeout(clickTimeoutRef.current); clickTimeoutRef.current = null; } - // Set a timeout for single-click action clickTimeoutRef.current = setTimeout(() => { clickTimeoutRef.current = null; onClick(); @@ -135,7 +133,6 @@ export function FileItem({ [openInEditor], ); - // Cleanup timeout on unmount useEffect(() => { return () => { if (clickTimeoutRef.current) { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileItem/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileItem/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileList.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileList.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileList.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileListGrouped.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileListGrouped.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileListGrouped.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileListGrouped.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileListTree.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileListTree.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/FileListTree.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/FileListTree.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FileList/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileList/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FolderRow/FolderRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FolderRow/FolderRow.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FolderRow/FolderRow.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FolderRow/FolderRow.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FolderRow/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FolderRow/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/FolderRow/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FolderRow/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/ViewModeToggle.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ViewModeToggle/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/components/ViewModeToggle/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ViewModeToggle/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/hooks/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/hooks/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/hooks/usePathActions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/usePathActions.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/hooks/usePathActions.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/usePathActions.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/types.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/types.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/types.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/utils/date.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/utils/date.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/utils/date.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/utils/date.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/utils/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/utils/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/utils/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/utils/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/utils/status.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/utils/status.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/utils/status.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/utils/status.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx new file mode 100644 index 00000000000..2b7a51f58ec --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx @@ -0,0 +1,359 @@ +import { useParams } from "@tanstack/react-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Tree, type TreeApi } from "react-arborist"; +import { dragDropManager } from "renderer/lib/dnd"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useFileExplorerStore } from "renderer/stores/file-explorer"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { + DirectoryEntry, + FileTreeNode as FileTreeNodeType, +} from "shared/file-tree-types"; +import useResizeObserver from "use-resize-observer"; +import { DeleteConfirmDialog } from "./components/DeleteConfirmDialog"; +import { FileTreeContextMenu } from "./components/FileTreeContextMenu"; +import { FileTreeNode } from "./components/FileTreeNode"; +import { FileTreeToolbar } from "./components/FileTreeToolbar"; +import { NewItemInput } from "./components/NewItemInput"; +import { OVERSCAN_COUNT, ROW_HEIGHT, TREE_INDENT } from "./constants"; +import { useFileTreeActions } from "./hooks/useFileTreeActions"; +import type { NewItemMode } from "./types"; + +export function FilesView() { + const { workspaceId } = useParams({ strict: false }); + const { data: workspace } = electronTrpc.workspaces.get.useQuery( + { id: workspaceId ?? "" }, + { enabled: !!workspaceId }, + ); + const worktreePath = workspace?.worktreePath; + + const treeRef = useRef>(null); + const { ref: containerRef, height: treeHeight = 400 } = useResizeObserver(); + + const { + searchTerm, + showHiddenFiles, + toggleFolder, + collapseAll, + setSelectedItems, + setSearchTerm, + toggleHiddenFiles, + } = useFileExplorerStore(); + + const currentSearchTerm = worktreePath ? searchTerm[worktreePath] || "" : ""; + + const [childrenCache, setChildrenCache] = useState< + Record + >({}); + const [loadingFolders, setLoadingFolders] = useState>(new Set()); + const trpcUtils = electronTrpc.useUtils(); + + const { + data: rootEntries, + isLoading, + refetch, + } = electronTrpc.filesystem.readDirectory.useQuery( + { + dirPath: worktreePath || "", + rootPath: worktreePath || "", + includeHidden: showHiddenFiles, + }, + { + enabled: !!worktreePath, + staleTime: 5000, + }, + ); + + const entriesToNodes = useCallback( + (entries: DirectoryEntry[]): FileTreeNodeType[] => { + return entries.map((entry) => { + if (!entry.isDirectory) { + return { ...entry, children: undefined }; + } + + const cachedChildren = childrenCache[entry.path]; + if (cachedChildren) { + return { + ...entry, + children: entriesToNodes(cachedChildren), + }; + } + + return { ...entry, children: null }; + }); + }, + [childrenCache], + ); + + const treeData = useMemo((): FileTreeNodeType[] => { + if (!rootEntries) return []; + return entriesToNodes(rootEntries); + }, [rootEntries, entriesToNodes]); + + const loadChildren = useCallback( + async (folderPath: string) => { + if ( + !worktreePath || + childrenCache[folderPath] || + loadingFolders.has(folderPath) + ) { + return; + } + + setLoadingFolders((prev) => new Set(prev).add(folderPath)); + + try { + const children = await trpcUtils.filesystem.readDirectory.fetch({ + dirPath: folderPath, + rootPath: worktreePath, + includeHidden: showHiddenFiles, + }); + + setChildrenCache((prev) => ({ + ...prev, + [folderPath]: children, + })); + } catch (error) { + console.error("[FilesView] Failed to load children:", { + folderPath, + error, + }); + } finally { + setLoadingFolders((prev) => { + const next = new Set(prev); + next.delete(folderPath); + return next; + }); + } + }, + [worktreePath, childrenCache, loadingFolders, showHiddenFiles, trpcUtils], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset on these changes + useEffect(() => { + setChildrenCache({}); + }, [worktreePath, showHiddenFiles]); + + const { createFile, createDirectory, rename, deleteItems, isDeleting } = + useFileTreeActions({ + worktreePath, + onRefresh: () => refetch(), + }); + + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); + + const [newItemMode, setNewItemMode] = useState(null); + const [newItemParentPath, setNewItemParentPath] = useState(""); + const [deleteNode, setDeleteNode] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [contextMenuNode, setContextMenuNode] = + useState(null); + + const handleActivate = useCallback( + (node: { data: FileTreeNodeType }) => { + if (!workspaceId || !worktreePath || node.data.isDirectory) return; + + addFileViewerPane(workspaceId, { + filePath: node.data.relativePath, + }); + }, + [workspaceId, worktreePath, addFileViewerPane], + ); + + const handleSelect = useCallback( + (nodes: { data: FileTreeNodeType }[]) => { + if (!worktreePath) return; + setSelectedItems( + worktreePath, + nodes.map((n) => n.data.id), + ); + }, + [worktreePath, setSelectedItems], + ); + + const handleToggle = useCallback( + (id: string) => { + if (!worktreePath) return; + toggleFolder(worktreePath, id); + + const node = treeRef.current?.get(id); + if (node?.data.isDirectory && !node.isOpen) { + loadChildren(node.data.path); + } + }, + [worktreePath, toggleFolder, loadChildren], + ); + + const handleRename = useCallback( + ({ id, name }: { id: string; name: string }) => { + const node = treeData.find((n) => n.id === id); + if (node) { + rename(node.path, name); + } + }, + [treeData, rename], + ); + + const handleNewFile = useCallback((parentPath: string) => { + setNewItemMode("file"); + setNewItemParentPath(parentPath); + }, []); + + const handleNewFolder = useCallback((parentPath: string) => { + setNewItemMode("folder"); + setNewItemParentPath(parentPath); + }, []); + + const handleNewItemSubmit = useCallback( + (name: string) => { + if (newItemMode === "file") { + createFile(newItemParentPath, name); + } else if (newItemMode === "folder") { + createDirectory(newItemParentPath, name); + } + setNewItemMode(null); + setNewItemParentPath(""); + }, + [newItemMode, newItemParentPath, createFile, createDirectory], + ); + + const handleNewItemCancel = useCallback(() => { + setNewItemMode(null); + setNewItemParentPath(""); + }, []); + + const handleDeleteRequest = useCallback((node: FileTreeNodeType) => { + setDeleteNode(node); + setShowDeleteDialog(true); + }, []); + + const handleDeleteConfirm = useCallback(() => { + if (deleteNode) { + deleteItems([deleteNode.path]); + } + setShowDeleteDialog(false); + setDeleteNode(null); + }, [deleteNode, deleteItems]); + + const handleContextMenuRename = useCallback((node: FileTreeNodeType) => { + treeRef.current?.get(node.id)?.edit(); + }, []); + + const handleSearchChange = useCallback( + (term: string) => { + if (!worktreePath) return; + setSearchTerm(worktreePath, term); + }, + [worktreePath, setSearchTerm], + ); + + const handleCollapseAll = useCallback(() => { + if (!worktreePath) return; + collapseAll(worktreePath); + treeRef.current?.closeAll(); + }, [worktreePath, collapseAll]); + + const handleRefresh = useCallback(() => { + setChildrenCache({}); + refetch(); + }, [refetch]); + + if (!worktreePath) { + return ( +
+ No workspace selected +
+ ); + } + + if (isLoading) { + return ( +
+ Loading files... +
+ ); + } + + return ( +
+ handleNewFile(worktreePath)} + onNewFolder={() => handleNewFolder(worktreePath)} + onCollapseAll={handleCollapseAll} + onRefresh={handleRefresh} + showHiddenFiles={showHiddenFiles} + onToggleHiddenFiles={toggleHiddenFiles} + /> + + + {/* biome-ignore lint/a11y/noStaticElementInteractions: context menu handler for tree container */} +
{ + const nodeEl = (e.target as HTMLElement).closest("[data-node-id]"); + if (nodeEl) { + const nodeId = nodeEl.getAttribute("data-node-id"); + setContextMenuNode( + treeRef.current?.get(nodeId || "")?.data || null, + ); + } else { + setContextMenuNode(null); + } + }} + > + {newItemMode && newItemParentPath === worktreePath && ( + + )} + + + ref={treeRef} + data={treeData} + width="100%" + height={treeHeight} + rowHeight={ROW_HEIGHT} + indent={TREE_INDENT} + overscanCount={OVERSCAN_COUNT} + idAccessor="id" + childrenAccessor="children" + openByDefault={false} + disableMultiSelection={false} + searchTerm={currentSearchTerm} + searchMatch={(node, term) => + node.data.name.toLowerCase().includes(term.toLowerCase()) + } + onActivate={handleActivate} + onSelect={handleSelect} + onToggle={handleToggle} + onRename={handleRename} + dndManager={dragDropManager} + > + {FileTreeNode} + +
+
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/DeleteConfirmDialog/DeleteConfirmDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/DeleteConfirmDialog/DeleteConfirmDialog.tsx new file mode 100644 index 00000000000..23a2a52ef50 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/DeleteConfirmDialog/DeleteConfirmDialog.tsx @@ -0,0 +1,65 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import type { FileTreeNode } from "shared/file-tree-types"; + +interface DeleteConfirmDialogProps { + node: FileTreeNode | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isDeleting?: boolean; +} + +export function DeleteConfirmDialog({ + node, + open, + onOpenChange, + onConfirm, + isDeleting = false, +}: DeleteConfirmDialogProps) { + if (!node) return null; + + const itemType = node.isDirectory ? "folder" : "file"; + const title = `Delete ${itemType} "${node.name}"?`; + const description = node.isDirectory + ? "This folder and all its contents will be moved to the trash. This action can be undone from the system trash." + : "This file will be moved to the trash. This action can be undone from the system trash."; + + return ( + + + + {title} + {description} + + + + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/DeleteConfirmDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/DeleteConfirmDialog/index.ts new file mode 100644 index 00000000000..9ad45826126 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/DeleteConfirmDialog/index.ts @@ -0,0 +1 @@ +export { DeleteConfirmDialog } from "./DeleteConfirmDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/FileTreeContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/FileTreeContextMenu.tsx new file mode 100644 index 00000000000..0776dbf2733 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/FileTreeContextMenu.tsx @@ -0,0 +1,109 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { + LuClipboard, + LuCopy, + LuExternalLink, + LuFile, + LuFolder, + LuFolderOpen, + LuPencil, + LuTrash2, +} from "react-icons/lu"; +import type { FileTreeNode } from "shared/file-tree-types"; +import { usePathActions } from "../../../ChangesView/hooks"; + +interface FileTreeContextMenuProps { + children: React.ReactNode; + node: FileTreeNode | null; + worktreePath: string; + onNewFile: (parentPath: string) => void; + onNewFolder: (parentPath: string) => void; + onRename: (node: FileTreeNode) => void; + onDelete: (node: FileTreeNode) => void; +} + +export function FileTreeContextMenu({ + children, + node, + worktreePath, + onNewFile, + onNewFolder, + onRename, + onDelete, +}: FileTreeContextMenuProps) { + const targetPath = node?.path ?? worktreePath; + const parentPath = node?.isDirectory ? node.path : worktreePath; + + const { copyPath, copyRelativePath, revealInFinder, openInEditor } = + usePathActions({ + absolutePath: targetPath, + relativePath: node?.relativePath, + cwd: worktreePath, + }); + + return ( + + + {children} + + + onNewFile(parentPath)}> + + New File + + onNewFolder(parentPath)}> + + New Folder + + + {node && ( + <> + + + + + Copy Path + + + + Copy Relative Path + + + + + + + Reveal in Finder + + {!node.isDirectory && ( + + + Open in Editor + + )} + + + + onRename(node)}> + + Rename + + onDelete(node)} + className="text-destructive focus:text-destructive" + > + + Delete + + + )} + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/index.ts new file mode 100644 index 00000000000..7e6da9b2209 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/index.ts @@ -0,0 +1 @@ +export { FileTreeContextMenu } from "./FileTreeContextMenu"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/FileTreeNode.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/FileTreeNode.tsx new file mode 100644 index 00000000000..4916b71a1f1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/FileTreeNode.tsx @@ -0,0 +1,115 @@ +import { cn } from "@superset/ui/utils"; +import type { NodeRendererProps } from "react-arborist"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; +import type { FileTreeNode as FileTreeNodeType } from "shared/file-tree-types"; +import { getFileIcon } from "../../utils"; + +type FileTreeNodeProps = NodeRendererProps; + +export function FileTreeNode({ node, style, dragHandle }: FileTreeNodeProps) { + const { data } = node; + const { icon: Icon, color } = getFileIcon( + data.name, + data.isDirectory, + node.isOpen, + ); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + node.select(); + if (data.isDirectory) { + node.toggle(); + } + }; + + const handleDoubleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + node.activate(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (data.isDirectory) { + node.toggle(); + } else { + node.activate(); + } + } + }; + + return ( +
+ + {data.isDirectory ? ( + node.isOpen ? ( + + ) : ( + + ) + ) : null} + + + + + {node.isEditing ? ( + { + if (!data.isDirectory) { + const dotIndex = data.name.lastIndexOf("."); + if (dotIndex > 0) { + e.target.setSelectionRange(0, dotIndex); + return; + } + } + e.target.select(); + }} + onBlur={() => node.reset()} + onKeyDown={(e) => { + if (e.key === "Enter") { + const newName = e.currentTarget.value.trim(); + if (newName && newName !== data.name) { + node.submit(newName); + } else { + node.reset(); + } + } + if (e.key === "Escape") { + node.reset(); + } + }} + className={cn( + "flex-1 min-w-0 px-1 py-0 text-xs bg-background border border-ring rounded outline-none", + )} + /> + ) : ( + + {data.name} + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/index.ts new file mode 100644 index 00000000000..f7f68a0b3cd --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/index.ts @@ -0,0 +1 @@ +export { FileTreeNode } from "./FileTreeNode"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx new file mode 100644 index 00000000000..95277a5f4da --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx @@ -0,0 +1,148 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useCallback, useState } from "react"; +import { + LuChevronsDownUp, + LuEye, + LuEyeOff, + LuFilePlus, + LuFolderPlus, + LuRefreshCw, +} from "react-icons/lu"; +import { SEARCH_DEBOUNCE_MS } from "../../constants"; + +interface FileTreeToolbarProps { + searchTerm: string; + onSearchChange: (term: string) => void; + onNewFile: () => void; + onNewFolder: () => void; + onCollapseAll: () => void; + onRefresh: () => void; + showHiddenFiles: boolean; + onToggleHiddenFiles: () => void; + isRefreshing?: boolean; +} + +export function FileTreeToolbar({ + searchTerm, + onSearchChange, + onNewFile, + onNewFolder, + onCollapseAll, + onRefresh, + showHiddenFiles, + onToggleHiddenFiles, + isRefreshing = false, +}: FileTreeToolbarProps) { + const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm); + + const handleSearchChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setLocalSearchTerm(value); + + const timeoutId = setTimeout(() => { + onSearchChange(value); + }, SEARCH_DEBOUNCE_MS); + + return () => clearTimeout(timeoutId); + }, + [onSearchChange], + ); + + return ( +
+ + +
+ + + + + New File + + + + + + + New Folder + + +
+ + + + + + + {showHiddenFiles ? "Hide Hidden Files" : "Show Hidden Files"} + + + + + + + + Collapse All + + + + + + + Refresh + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/index.ts new file mode 100644 index 00000000000..d5c9cca2fa4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/index.ts @@ -0,0 +1 @@ +export { FileTreeToolbar } from "./FileTreeToolbar"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/NewItemInput/NewItemInput.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/NewItemInput/NewItemInput.tsx new file mode 100644 index 00000000000..20cdca8eb22 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/NewItemInput/NewItemInput.tsx @@ -0,0 +1,72 @@ +import { cn } from "@superset/ui/utils"; +import { useEffect, useRef, useState } from "react"; +import { LuFile, LuFolder } from "react-icons/lu"; +import type { NewItemMode } from "../../types"; + +interface NewItemInputProps { + mode: NewItemMode; + parentPath: string; + onSubmit: (name: string) => void; + onCancel: () => void; + level?: number; +} + +export function NewItemInput({ + mode, + parentPath: _parentPath, + onSubmit, + onCancel, + level = 0, +}: NewItemInputProps) { + const [value, setValue] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleSubmit = () => { + const trimmed = value.trim(); + if (trimmed) { + onSubmit(trimmed); + } else { + onCancel(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(); + } + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }; + + const Icon = mode === "folder" ? LuFolder : LuFile; + + return ( +
+ + + setValue(e.target.value)} + onBlur={handleSubmit} + onKeyDown={handleKeyDown} + placeholder={mode === "folder" ? "folder name" : "file name"} + className={cn( + "flex-1 min-w-0 px-1 py-0 text-xs", + "bg-background border border-ring rounded outline-none", + )} + /> +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/NewItemInput/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/NewItemInput/index.ts new file mode 100644 index 00000000000..23e9c65a4c7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/NewItemInput/index.ts @@ -0,0 +1 @@ +export { NewItemInput } from "./NewItemInput"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants.ts new file mode 100644 index 00000000000..c4819eb8b20 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants.ts @@ -0,0 +1,30 @@ +export const ROW_HEIGHT = 28; +export const TREE_INDENT = 16; +export const OVERSCAN_COUNT = 10; +export const SEARCH_DEBOUNCE_MS = 150; + +export const DEFAULT_IGNORE_PATTERNS = [ + "**/node_modules/**", + "**/.git/**", + "**/dist/**", + "**/build/**", + "**/.next/**", + "**/.turbo/**", + "**/coverage/**", +]; + +export const SPECIAL_FOLDERS = { + node_modules: "package", + ".git": "git", + src: "folder-src", + components: "folder-components", + lib: "folder-lib", + utils: "folder-utils", + hooks: "folder-hooks", + styles: "folder-styles", + public: "folder-public", + assets: "folder-assets", + tests: "folder-test", + __tests__: "folder-test", + docs: "folder-docs", +} as const; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTree.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTree.ts new file mode 100644 index 00000000000..b168736c915 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTree.ts @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useFileExplorerStore } from "renderer/stores/file-explorer"; +import type { FileTreeNode } from "shared/file-tree-types"; + +interface UseFileTreeProps { + worktreePath: string | undefined; +} + +interface UseFileTreeReturn { + treeData: FileTreeNode[]; + isLoading: boolean; + error: Error | null; + refetch: () => void; + loadChildren: (nodeId: string, nodePath: string) => Promise; +} + +export function useFileTree({ + worktreePath, +}: UseFileTreeProps): UseFileTreeReturn { + const [treeData, setTreeData] = useState([]); + const [childrenCache, setChildrenCache] = useState< + Record + >({}); + + const { showHiddenFiles, expandedFolders } = useFileExplorerStore(); + const currentExpandedFolders = worktreePath + ? expandedFolders[worktreePath] || [] + : []; + + const trpcUtils = electronTrpc.useUtils(); + + const { + data: rootEntries, + isLoading, + error, + refetch, + } = electronTrpc.filesystem.readDirectory.useQuery( + { + dirPath: worktreePath || "", + rootPath: worktreePath || "", + includeHidden: showHiddenFiles, + }, + { + enabled: !!worktreePath, + staleTime: 5000, + }, + ); + + const rootNodes = useMemo((): FileTreeNode[] => { + if (!rootEntries) return []; + + return rootEntries.map((entry) => ({ + ...entry, + children: entry.isDirectory ? null : undefined, + })); + }, [rootEntries]); + + const buildTree = useCallback( + (nodes: FileTreeNode[]): FileTreeNode[] => { + return nodes.map((node) => { + if (!node.isDirectory) { + return node; + } + + const isExpanded = currentExpandedFolders.includes(node.id); + const cachedChildren = childrenCache[node.id]; + + if (!isExpanded) { + return { ...node, children: null }; + } + + if (cachedChildren) { + return { + ...node, + children: buildTree(cachedChildren), + }; + } + + return { ...node, children: null, isLoading: true }; + }); + }, + [currentExpandedFolders, childrenCache], + ); + + useEffect(() => { + setTreeData(buildTree(rootNodes)); + }, [rootNodes, buildTree]); + + const loadChildren = useCallback( + async (nodeId: string, nodePath: string): Promise => { + if (!worktreePath) return []; + + if (childrenCache[nodeId]) { + return childrenCache[nodeId]; + } + + try { + const entries = await trpcUtils.filesystem.readDirectory.fetch({ + dirPath: nodePath, + rootPath: worktreePath, + includeHidden: showHiddenFiles, + }); + + const childNodes: FileTreeNode[] = entries.map((entry) => ({ + ...entry, + children: entry.isDirectory ? null : undefined, + })); + + setChildrenCache((prev) => ({ + ...prev, + [nodeId]: childNodes, + })); + + return childNodes; + } catch (err) { + console.error("[useFileTree] Failed to load children:", { + nodeId, + nodePath, + error: err, + }); + return []; + } + }, + [worktreePath, showHiddenFiles, childrenCache, trpcUtils], + ); + + return { + treeData, + isLoading, + error: error as Error | null, + refetch: () => { + setChildrenCache({}); + refetch(); + }, + loadChildren, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTreeActions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTreeActions.ts new file mode 100644 index 00000000000..0366c413948 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTreeActions.ts @@ -0,0 +1,155 @@ +import { toast } from "@superset/ui/sonner"; +import { useCallback } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +interface UseFileTreeActionsProps { + worktreePath: string | undefined; + onRefresh: () => void; +} + +export function useFileTreeActions({ + worktreePath: _worktreePath, + onRefresh, +}: UseFileTreeActionsProps) { + const createFileMutation = electronTrpc.filesystem.createFile.useMutation({ + onSuccess: (data) => { + toast.success(`Created ${data.path.split("/").pop()}`); + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to create file: ${error.message}`); + }, + }); + + const createDirectoryMutation = + electronTrpc.filesystem.createDirectory.useMutation({ + onSuccess: (data) => { + toast.success(`Created ${data.path.split("/").pop()}`); + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to create folder: ${error.message}`); + }, + }); + + const renameMutation = electronTrpc.filesystem.rename.useMutation({ + onSuccess: (data) => { + toast.success(`Renamed to ${data.newPath.split("/").pop()}`); + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to rename: ${error.message}`); + }, + }); + + const deleteMutation = electronTrpc.filesystem.delete.useMutation({ + onSuccess: (data) => { + const count = data.deleted.length; + if (count === 1) { + toast.success(`Moved to trash`); + } else { + toast.success(`Moved ${count} items to trash`); + } + if (data.errors.length > 0) { + toast.error(`Failed to delete ${data.errors.length} items`); + } + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to delete: ${error.message}`); + }, + }); + + const moveMutation = electronTrpc.filesystem.move.useMutation({ + onSuccess: (data) => { + const count = data.moved.length; + if (count === 1) { + toast.success(`Moved ${data.moved[0].to.split("/").pop()}`); + } else { + toast.success(`Moved ${count} items`); + } + if (data.errors.length > 0) { + toast.error(`Failed to move ${data.errors.length} items`); + } + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to move: ${error.message}`); + }, + }); + + const copyMutation = electronTrpc.filesystem.copy.useMutation({ + onSuccess: (data) => { + const count = data.copied.length; + if (count === 1) { + toast.success(`Copied ${data.copied[0].to.split("/").pop()}`); + } else { + toast.success(`Copied ${count} items`); + } + if (data.errors.length > 0) { + toast.error(`Failed to copy ${data.errors.length} items`); + } + onRefresh(); + }, + onError: (error) => { + toast.error(`Failed to copy: ${error.message}`); + }, + }); + + const createFile = useCallback( + (dirPath: string, fileName: string, content = "") => { + createFileMutation.mutate({ dirPath, fileName, content }); + }, + [createFileMutation], + ); + + const createDirectory = useCallback( + (parentPath: string, dirName: string) => { + createDirectoryMutation.mutate({ parentPath, dirName }); + }, + [createDirectoryMutation], + ); + + const rename = useCallback( + (oldPath: string, newName: string) => { + renameMutation.mutate({ oldPath, newName }); + }, + [renameMutation], + ); + + const deleteItems = useCallback( + (paths: string[], permanent = false) => { + deleteMutation.mutate({ paths, permanent }); + }, + [deleteMutation], + ); + + const moveItems = useCallback( + (sourcePaths: string[], destinationDir: string) => { + moveMutation.mutate({ sourcePaths, destinationDir }); + }, + [moveMutation], + ); + + const copyItems = useCallback( + (sourcePaths: string[], destinationDir: string) => { + copyMutation.mutate({ sourcePaths, destinationDir }); + }, + [copyMutation], + ); + + return { + createFile, + createDirectory, + rename, + deleteItems, + moveItems, + copyItems, + isCreatingFile: createFileMutation.isPending, + isCreatingDirectory: createDirectoryMutation.isPending, + isRenaming: renameMutation.isPending, + isDeleting: deleteMutation.isPending, + isMoving: moveMutation.isPending, + isCopying: copyMutation.isPending, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/index.ts new file mode 100644 index 00000000000..83607dd39bc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/index.ts @@ -0,0 +1 @@ +export { FilesView } from "./FilesView"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/types.ts new file mode 100644 index 00000000000..2c131994bbc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/types.ts @@ -0,0 +1,13 @@ +import type { NodeRendererProps } from "react-arborist"; +import type { FileTreeNode } from "shared/file-tree-types"; + +export type FileTreeNodeProps = NodeRendererProps; + +export type OnFileOpen = (node: FileTreeNode) => void; + +export type NewItemMode = "file" | "folder" | null; + +export interface TreeActionResult { + success: boolean; + error?: string; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/file-icons.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/file-icons.ts new file mode 100644 index 00000000000..22fbe21c922 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/file-icons.ts @@ -0,0 +1,241 @@ +import type { IconType } from "react-icons"; +import { + LuDatabase, + LuFile, + LuFileArchive, + LuFileAudio, + LuFileCode, + LuFileImage, + LuFileJson, + LuFileSpreadsheet, + LuFileText, + LuFileVideo, + LuFolder, + LuFolderOpen, + LuGitBranch, + LuLock, + LuPackage, + LuSettings, + LuTerminal, +} from "react-icons/lu"; +import { + SiCss3, + SiDocker, + SiGo, + SiHtml5, + SiJavascript, + SiMarkdown, + SiPython, + SiReact, + SiRust, + SiTypescript, + SiYaml, +} from "react-icons/si"; + +interface FileIconConfig { + icon: IconType; + color: string; +} + +const EXTENSION_ICONS: Record = { + // TypeScript + ts: { icon: SiTypescript, color: "text-blue-500" }, + tsx: { icon: SiReact, color: "text-cyan-500" }, + mts: { icon: SiTypescript, color: "text-blue-500" }, + cts: { icon: SiTypescript, color: "text-blue-500" }, + "d.ts": { icon: SiTypescript, color: "text-blue-400" }, + + // JavaScript + js: { icon: SiJavascript, color: "text-yellow-500" }, + jsx: { icon: SiReact, color: "text-cyan-500" }, + mjs: { icon: SiJavascript, color: "text-yellow-500" }, + cjs: { icon: SiJavascript, color: "text-yellow-500" }, + + // Web + html: { icon: SiHtml5, color: "text-orange-500" }, + htm: { icon: SiHtml5, color: "text-orange-500" }, + css: { icon: SiCss3, color: "text-blue-400" }, + scss: { icon: SiCss3, color: "text-pink-500" }, + sass: { icon: SiCss3, color: "text-pink-500" }, + less: { icon: SiCss3, color: "text-purple-500" }, + + // Data formats + json: { icon: LuFileJson, color: "text-yellow-600" }, + jsonc: { icon: LuFileJson, color: "text-yellow-600" }, + yaml: { icon: SiYaml, color: "text-red-400" }, + yml: { icon: SiYaml, color: "text-red-400" }, + toml: { icon: LuSettings, color: "text-orange-400" }, + xml: { icon: LuFileCode, color: "text-orange-500" }, + csv: { icon: LuFileSpreadsheet, color: "text-green-500" }, + + // Documentation + md: { icon: SiMarkdown, color: "text-slate-400" }, + mdx: { icon: SiMarkdown, color: "text-yellow-400" }, + txt: { icon: LuFileText, color: "text-muted-foreground" }, + rst: { icon: LuFileText, color: "text-muted-foreground" }, + + // Python + py: { icon: SiPython, color: "text-blue-400" }, + pyw: { icon: SiPython, color: "text-blue-400" }, + pyi: { icon: SiPython, color: "text-blue-300" }, + + // Rust + rs: { icon: SiRust, color: "text-orange-600" }, + + // Go + go: { icon: SiGo, color: "text-cyan-400" }, + + // Shell + sh: { icon: LuTerminal, color: "text-green-400" }, + bash: { icon: LuTerminal, color: "text-green-400" }, + zsh: { icon: LuTerminal, color: "text-green-400" }, + fish: { icon: LuTerminal, color: "text-green-400" }, + + // Images + png: { icon: LuFileImage, color: "text-purple-400" }, + jpg: { icon: LuFileImage, color: "text-purple-400" }, + jpeg: { icon: LuFileImage, color: "text-purple-400" }, + gif: { icon: LuFileImage, color: "text-purple-400" }, + svg: { icon: LuFileImage, color: "text-orange-400" }, + webp: { icon: LuFileImage, color: "text-purple-400" }, + ico: { icon: LuFileImage, color: "text-purple-400" }, + + // Video + mp4: { icon: LuFileVideo, color: "text-pink-400" }, + webm: { icon: LuFileVideo, color: "text-pink-400" }, + mov: { icon: LuFileVideo, color: "text-pink-400" }, + avi: { icon: LuFileVideo, color: "text-pink-400" }, + + // Audio + mp3: { icon: LuFileAudio, color: "text-red-400" }, + wav: { icon: LuFileAudio, color: "text-red-400" }, + ogg: { icon: LuFileAudio, color: "text-red-400" }, + flac: { icon: LuFileAudio, color: "text-red-400" }, + + // Archives + zip: { icon: LuFileArchive, color: "text-yellow-500" }, + tar: { icon: LuFileArchive, color: "text-yellow-500" }, + gz: { icon: LuFileArchive, color: "text-yellow-500" }, + "7z": { icon: LuFileArchive, color: "text-yellow-500" }, + rar: { icon: LuFileArchive, color: "text-yellow-500" }, + + // Database + sql: { icon: LuDatabase, color: "text-blue-400" }, + sqlite: { icon: LuDatabase, color: "text-blue-400" }, + db: { icon: LuDatabase, color: "text-blue-400" }, + + // Docker + dockerfile: { icon: SiDocker, color: "text-blue-400" }, + + // Config files + env: { icon: LuLock, color: "text-yellow-500" }, + "env.local": { icon: LuLock, color: "text-yellow-500" }, + "env.development": { icon: LuLock, color: "text-yellow-500" }, + "env.production": { icon: LuLock, color: "text-yellow-500" }, + gitignore: { icon: LuGitBranch, color: "text-orange-400" }, + gitattributes: { icon: LuGitBranch, color: "text-orange-400" }, + editorconfig: { icon: LuSettings, color: "text-muted-foreground" }, + prettierrc: { icon: LuSettings, color: "text-pink-400" }, + eslintrc: { icon: LuSettings, color: "text-purple-400" }, +}; + +const FILENAME_ICONS: Record = { + "package.json": { icon: LuPackage, color: "text-green-500" }, + "package-lock.json": { icon: LuPackage, color: "text-green-500" }, + "bun.lockb": { icon: LuPackage, color: "text-pink-400" }, + "yarn.lock": { icon: LuPackage, color: "text-blue-400" }, + "pnpm-lock.yaml": { icon: LuPackage, color: "text-yellow-500" }, + Dockerfile: { icon: SiDocker, color: "text-blue-400" }, + "docker-compose.yml": { icon: SiDocker, color: "text-blue-400" }, + "docker-compose.yaml": { icon: SiDocker, color: "text-blue-400" }, + ".gitignore": { icon: LuGitBranch, color: "text-orange-400" }, + ".gitattributes": { icon: LuGitBranch, color: "text-orange-400" }, + ".env": { icon: LuLock, color: "text-yellow-500" }, + ".env.local": { icon: LuLock, color: "text-yellow-500" }, + ".env.development": { icon: LuLock, color: "text-yellow-500" }, + ".env.production": { icon: LuLock, color: "text-yellow-500" }, + "tsconfig.json": { icon: SiTypescript, color: "text-blue-500" }, + "jsconfig.json": { icon: SiJavascript, color: "text-yellow-500" }, + README: { icon: SiMarkdown, color: "text-slate-400" }, + "README.md": { icon: SiMarkdown, color: "text-slate-400" }, + LICENSE: { icon: LuFileText, color: "text-yellow-500" }, + "LICENSE.md": { icon: LuFileText, color: "text-yellow-500" }, +}; + +const FOLDER_ICONS: Record = { + node_modules: { icon: LuPackage, color: "text-green-500" }, + ".git": { icon: LuGitBranch, color: "text-orange-400" }, + src: { icon: LuFolder, color: "text-blue-400" }, + dist: { icon: LuFolder, color: "text-yellow-500" }, + build: { icon: LuFolder, color: "text-yellow-500" }, + public: { icon: LuFolder, color: "text-green-400" }, + assets: { icon: LuFolder, color: "text-purple-400" }, + components: { icon: LuFolder, color: "text-cyan-400" }, + lib: { icon: LuFolder, color: "text-orange-400" }, + utils: { icon: LuFolder, color: "text-pink-400" }, + hooks: { icon: LuFolder, color: "text-purple-400" }, + styles: { icon: LuFolder, color: "text-pink-400" }, + tests: { icon: LuFolder, color: "text-green-400" }, + __tests__: { icon: LuFolder, color: "text-green-400" }, + docs: { icon: LuFolder, color: "text-blue-400" }, +}; + +export function getFileIcon( + fileName: string, + isDirectory: boolean, + isOpen = false, +): FileIconConfig { + if (isDirectory) { + const folderIcon = FOLDER_ICONS[fileName]; + if (folderIcon) { + return { + icon: isOpen ? LuFolderOpen : folderIcon.icon, + color: folderIcon.color, + }; + } + return { + icon: isOpen ? LuFolderOpen : LuFolder, + color: "text-amber-500", + }; + } + + const filenameIcon = FILENAME_ICONS[fileName]; + if (filenameIcon) { + return filenameIcon; + } + + const extension = getExtension(fileName); + if (extension) { + const extIcon = EXTENSION_ICONS[extension]; + if (extIcon) { + return extIcon; + } + } + + return { + icon: LuFile, + color: "text-muted-foreground", + }; +} + +function getExtension(fileName: string): string | null { + if (fileName.endsWith(".d.ts")) { + return "d.ts"; + } + if (fileName.endsWith(".env.local")) { + return "env.local"; + } + if (fileName.endsWith(".env.development")) { + return "env.development"; + } + if (fileName.endsWith(".env.production")) { + return "env.production"; + } + + const parts = fileName.split("."); + if (parts.length > 1) { + return parts[parts.length - 1].toLowerCase(); + } + + return null; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/index.ts new file mode 100644 index 00000000000..8928b71a2fa --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/index.ts @@ -0,0 +1 @@ +export { getFileIcon } from "./file-icons"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx new file mode 100644 index 00000000000..81ddf303493 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx @@ -0,0 +1,188 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useParams } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { + LuExpand, + LuFile, + LuGitCompareArrows, + LuShrink, + LuX, +} from "react-icons/lu"; +import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + RightSidebarTab, + SidebarMode, + useSidebarStore, +} from "renderer/stores/sidebar-state"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { ChangeCategory, ChangedFile } from "shared/changes-types"; +import { useScrollContext } from "../ChangesContent"; +import { ChangesView } from "./ChangesView"; +import { FilesView } from "./FilesView"; + +function TabButton({ + isActive, + onClick, + icon, + label, +}: { + isActive: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; +}) { + return ( + + ); +} + +export function RightSidebar() { + const { workspaceId } = useParams({ strict: false }); + const { data: workspace } = electronTrpc.workspaces.get.useQuery( + { id: workspaceId ?? "" }, + { enabled: !!workspaceId }, + ); + const worktreePath = workspace?.worktreePath; + const { + currentMode, + rightSidebarTab, + setRightSidebarTab, + toggleSidebar, + setMode, + } = useSidebarStore(); + const isExpanded = currentMode === SidebarMode.Changes; + + const handleExpandToggle = () => { + setMode(isExpanded ? SidebarMode.Tabs : SidebarMode.Changes); + }; + + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); + const trpcUtils = electronTrpc.useUtils(); + const { scrollToFile } = useScrollContext(); + + const invalidateFileContent = useCallback( + (filePath: string) => { + if (!worktreePath) return; + + Promise.all([ + trpcUtils.changes.readWorkingFile.invalidate({ + worktreePath, + filePath, + }), + trpcUtils.changes.getFileContents.invalidate({ + worktreePath, + filePath, + }), + ]).catch((error) => { + console.error( + "[RightSidebar/invalidateFileContent] Failed to invalidate file content queries:", + { worktreePath, filePath, error }, + ); + }); + }, + [worktreePath, trpcUtils], + ); + + const handleFileOpenPane = useCallback( + (file: ChangedFile, category: ChangeCategory, commitHash?: string) => { + if (!workspaceId || !worktreePath) return; + addFileViewerPane(workspaceId, { + filePath: file.path, + diffCategory: category, + commitHash, + oldPath: file.oldPath, + }); + invalidateFileContent(file.path); + }, + [workspaceId, worktreePath, addFileViewerPane, invalidateFileContent], + ); + + const handleFileScrollTo = useCallback( + (file: ChangedFile, category: ChangeCategory, commitHash?: string) => { + scrollToFile(file, category, commitHash); + }, + [scrollToFile], + ); + + const handleFileOpen = + workspaceId && worktreePath + ? isExpanded + ? handleFileScrollTo + : handleFileOpenPane + : undefined; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx deleted file mode 100644 index d024bbcffbc..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useParams } from "@tanstack/react-router"; -import { useCallback } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { SidebarMode, useSidebarStore } from "renderer/stores/sidebar-state"; -import { useTabsStore } from "renderer/stores/tabs/store"; -import type { ChangeCategory, ChangedFile } from "shared/changes-types"; -import { useScrollContext } from "../ChangesContent"; -import { ChangesView } from "./ChangesView"; - -export function Sidebar() { - const { workspaceId } = useParams({ strict: false }); - const { data: workspace } = electronTrpc.workspaces.get.useQuery( - { id: workspaceId ?? "" }, - { enabled: !!workspaceId }, - ); - const worktreePath = workspace?.worktreePath; - const { currentMode } = useSidebarStore(); - const isExpanded = currentMode === SidebarMode.Changes; - - const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); - const trpcUtils = electronTrpc.useUtils(); - const { scrollToFile } = useScrollContext(); - - const invalidateFileContent = useCallback( - (filePath: string) => { - if (!worktreePath) return; - - Promise.all([ - trpcUtils.changes.readWorkingFile.invalidate({ - worktreePath, - filePath, - }), - trpcUtils.changes.getFileContents.invalidate({ - worktreePath, - filePath, - }), - ]).catch((error) => { - console.error( - "[Sidebar/invalidateFileContent] Failed to invalidate file content queries:", - { worktreePath, filePath, error }, - ); - }); - }, - [worktreePath, trpcUtils], - ); - - const handleFileOpenPane = useCallback( - (file: ChangedFile, category: ChangeCategory, commitHash?: string) => { - if (!workspaceId || !worktreePath) return; - addFileViewerPane(workspaceId, { - filePath: file.path, - diffCategory: category, - commitHash, - oldPath: file.oldPath, - }); - invalidateFileContent(file.path); - }, - [workspaceId, worktreePath, addFileViewerPane, invalidateFileContent], - ); - - const handleFileScrollTo = useCallback( - (file: ChangedFile, category: ChangeCategory, commitHash?: string) => { - scrollToFile(file, category, commitHash); - }, - [scrollToFile], - ); - - const handleFileOpen = - workspaceId && worktreePath - ? isExpanded - ? handleFileScrollTo - : handleFileOpenPane - : undefined; - - return ( - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx index 7fe3f269c14..deeef9089ec 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx @@ -7,7 +7,7 @@ import { import { ResizablePanel } from "../../ResizablePanel"; import { ChangesContent, ScrollProvider } from "../ChangesContent"; import { ContentView } from "../ContentView"; -import { Sidebar } from "../Sidebar"; +import { RightSidebar } from "../RightSidebar"; export function WorkspaceLayout() { const { @@ -37,7 +37,7 @@ export function WorkspaceLayout() { handleSide="left" className={isExpanded ? "border-l-0" : undefined} > - + )} diff --git a/apps/desktop/src/renderer/stores/file-explorer.ts b/apps/desktop/src/renderer/stores/file-explorer.ts new file mode 100644 index 00000000000..a8ba95e361d --- /dev/null +++ b/apps/desktop/src/renderer/stores/file-explorer.ts @@ -0,0 +1,166 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +export type SortBy = "name" | "type" | "modified"; +export type SortDirection = "asc" | "desc"; + +interface FileExplorerState { + expandedFolders: Record; + selectedItems: Record; + searchTerm: Record; + showHiddenFiles: boolean; + sortBy: SortBy; + sortDirection: SortDirection; + toggleFolder: (worktreePath: string, folderId: string) => void; + setExpandedFolders: (worktreePath: string, folderIds: string[]) => void; + expandFolder: (worktreePath: string, folderId: string) => void; + collapseFolder: (worktreePath: string, folderId: string) => void; + collapseAll: (worktreePath: string) => void; + setSelectedItems: (worktreePath: string, items: string[]) => void; + addSelectedItem: (worktreePath: string, itemId: string) => void; + removeSelectedItem: (worktreePath: string, itemId: string) => void; + clearSelection: (worktreePath: string) => void; + setSearchTerm: (worktreePath: string, term: string) => void; + toggleHiddenFiles: () => void; + setSortBy: (sortBy: SortBy) => void; + setSortDirection: (direction: SortDirection) => void; +} + +export const useFileExplorerStore = create()( + devtools( + persist( + (set, get) => ({ + expandedFolders: {}, + selectedItems: {}, + searchTerm: {}, + showHiddenFiles: false, + sortBy: "name", + sortDirection: "asc", + + toggleFolder: (worktreePath, folderId) => { + const current = get().expandedFolders[worktreePath] || []; + const isExpanded = current.includes(folderId); + set({ + expandedFolders: { + ...get().expandedFolders, + [worktreePath]: isExpanded + ? current.filter((id) => id !== folderId) + : [...current, folderId], + }, + }); + }, + + setExpandedFolders: (worktreePath, folderIds) => { + set({ + expandedFolders: { + ...get().expandedFolders, + [worktreePath]: folderIds, + }, + }); + }, + + expandFolder: (worktreePath, folderId) => { + const current = get().expandedFolders[worktreePath] || []; + if (!current.includes(folderId)) { + set({ + expandedFolders: { + ...get().expandedFolders, + [worktreePath]: [...current, folderId], + }, + }); + } + }, + + collapseFolder: (worktreePath, folderId) => { + const current = get().expandedFolders[worktreePath] || []; + set({ + expandedFolders: { + ...get().expandedFolders, + [worktreePath]: current.filter((id) => id !== folderId), + }, + }); + }, + + collapseAll: (worktreePath) => { + set({ + expandedFolders: { + ...get().expandedFolders, + [worktreePath]: [], + }, + }); + }, + + setSelectedItems: (worktreePath, items) => { + set({ + selectedItems: { + ...get().selectedItems, + [worktreePath]: items, + }, + }); + }, + + addSelectedItem: (worktreePath, itemId) => { + const current = get().selectedItems[worktreePath] || []; + if (!current.includes(itemId)) { + set({ + selectedItems: { + ...get().selectedItems, + [worktreePath]: [...current, itemId], + }, + }); + } + }, + + removeSelectedItem: (worktreePath, itemId) => { + const current = get().selectedItems[worktreePath] || []; + set({ + selectedItems: { + ...get().selectedItems, + [worktreePath]: current.filter((id) => id !== itemId), + }, + }); + }, + + clearSelection: (worktreePath) => { + set({ + selectedItems: { + ...get().selectedItems, + [worktreePath]: [], + }, + }); + }, + + setSearchTerm: (worktreePath, term) => { + set({ + searchTerm: { + ...get().searchTerm, + [worktreePath]: term, + }, + }); + }, + + toggleHiddenFiles: () => { + set({ showHiddenFiles: !get().showHiddenFiles }); + }, + + setSortBy: (sortBy) => { + set({ sortBy }); + }, + + setSortDirection: (direction) => { + set({ sortDirection: direction }); + }, + }), + { + name: "file-explorer-store", + partialize: (state) => ({ + showHiddenFiles: state.showHiddenFiles, + sortBy: state.sortBy, + sortDirection: state.sortDirection, + expandedFolders: state.expandedFolders, + }), + }, + ), + { name: "FileExplorerStore" }, + ), +); diff --git a/apps/desktop/src/renderer/stores/sidebar-state.ts b/apps/desktop/src/renderer/stores/sidebar-state.ts index ea9c43f76c2..b198741e3aa 100644 --- a/apps/desktop/src/renderer/stores/sidebar-state.ts +++ b/apps/desktop/src/renderer/stores/sidebar-state.ts @@ -6,6 +6,11 @@ export enum SidebarMode { Changes = "changes", } +export enum RightSidebarTab { + Changes = "changes", + Files = "files", +} + const DEFAULT_SIDEBAR_WIDTH = 250; export const MIN_SIDEBAR_WIDTH = 200; export const MAX_SIDEBAR_WIDTH = 500; @@ -17,11 +22,13 @@ interface SidebarState { currentMode: SidebarMode; lastMode: SidebarMode; isResizing: boolean; + rightSidebarTab: RightSidebarTab; toggleSidebar: () => void; setSidebarOpen: (open: boolean) => void; setSidebarWidth: (width: number) => void; setMode: (mode: SidebarMode) => void; setIsResizing: (isResizing: boolean) => void; + setRightSidebarTab: (tab: RightSidebarTab) => void; } export const useSidebarStore = create()( @@ -34,6 +41,7 @@ export const useSidebarStore = create()( currentMode: SidebarMode.Tabs, lastMode: SidebarMode.Tabs, isResizing: false, + rightSidebarTab: RightSidebarTab.Changes, toggleSidebar: () => { const { isSidebarOpen, lastOpenSidebarWidth, currentMode, lastMode } = @@ -102,6 +110,10 @@ export const useSidebarStore = create()( setIsResizing: (isResizing) => { set({ isResizing }); }, + + setRightSidebarTab: (tab) => { + set({ rightSidebarTab: tab }); + }, }), { name: "sidebar-store", diff --git a/apps/desktop/src/shared/file-tree-types.ts b/apps/desktop/src/shared/file-tree-types.ts new file mode 100644 index 00000000000..946377e02e1 --- /dev/null +++ b/apps/desktop/src/shared/file-tree-types.ts @@ -0,0 +1,23 @@ +export interface FileTreeNode { + id: string; + name: string; + isDirectory: boolean; + path: string; + relativePath: string; + children?: FileTreeNode[] | null; + isLoading?: boolean; +} + +export interface FileSystemChangeEvent { + type: "add" | "addDir" | "unlink" | "unlinkDir" | "change"; + path: string; + relativePath: string; +} + +export interface DirectoryEntry { + id: string; + name: string; + path: string; + relativePath: string; + isDirectory: boolean; +} diff --git a/apps/desktop/src/shared/utils/branch.ts b/apps/desktop/src/shared/utils/branch.ts index d13f76d1811..d8b77456324 100644 --- a/apps/desktop/src/shared/utils/branch.ts +++ b/apps/desktop/src/shared/utils/branch.ts @@ -32,16 +32,21 @@ export function resolveBranchPrefix({ authorPrefix?: string | null; githubUsername?: string | null; }): string | null { + let prefix: string | null = null; switch (mode) { case "none": return null; case "custom": - return customPrefix || null; + prefix = customPrefix || null; + break; case "author": - return authorPrefix || null; + prefix = authorPrefix || null; + break; case "github": - return githubUsername || authorPrefix || null; + prefix = githubUsername || authorPrefix || null; + break; default: return null; } + return prefix ? sanitizeSegment(prefix) : null; } diff --git a/bun.lock b/bun.lock index ce1de1f2ecd..17a320f8b3c 100644 --- a/bun.lock +++ b/bun.lock @@ -262,6 +262,7 @@ "tree-kill": "^1.2.2", "trpc-electron": "^0.1.2", "tw-animate-css": "^1.4.0", + "use-resize-observer": "^9.1.0", "zod": "^4.3.5", "zustand": "^5.0.8", }, @@ -1228,6 +1229,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="], + "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], @@ -4866,6 +4869,8 @@ "use-latest-callback": ["use-latest-callback@0.2.6", "", { "peerDependencies": { "react": ">=16.8" } }, "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg=="], + "use-resize-observer": ["use-resize-observer@9.1.0", "", { "dependencies": { "@juggle/resize-observer": "^3.3.1" }, "peerDependencies": { "react": "16.8.0 - 18", "react-dom": "16.8.0 - 18" } }, "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow=="], + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-stick-to-bottom": ["use-stick-to-bottom@1.1.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ssUfMNvfH8a8hGLoAt5kcOsjbsVORknon2tbkECuf3EsVucFFBbyXl+Xnv3b58P8ZRuZelzO81fgb6M0eRo8cg=="],