diff --git a/.vscode/settings.json b/.vscode/settings.json index a0c9ac8..f806d8e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -57,7 +57,8 @@ // CSpell "cSpell.words": [ "asar", - "asynciterable" + "asynciterable", + "scrollview" ], "[json]": { "editor.defaultFormatter": "biomejs.biome" diff --git a/src/main/config/app.ts b/src/main/config/app.ts index a841dbb..837f471 100644 --- a/src/main/config/app.ts +++ b/src/main/config/app.ts @@ -9,6 +9,7 @@ const DEFAULT_APP_CONFIG: AppConfig = { version: STORE_CONFIG.configVersion, preferences: { startupBehavior: "reopen-last", + filesPaneWidth: 320, }, recentProjects: [], lfsLockCache: {}, diff --git a/src/main/config/dialogs.ts b/src/main/config/dialogs.ts index 598b503..26e676d 100644 --- a/src/main/config/dialogs.ts +++ b/src/main/config/dialogs.ts @@ -3,7 +3,7 @@ */ const DIALOG_CONFIG = { sizes: { - confirm: { width: 420, height: 210 }, + confirm: { width: 420, height: 230 }, error: { width: 480, height: 300 }, }, } diff --git a/src/main/lib/constants.ts b/src/main/lib/constants.ts index d3a7dc5..d9a2a90 100644 --- a/src/main/lib/constants.ts +++ b/src/main/lib/constants.ts @@ -33,6 +33,7 @@ const CONSTANTS = { projectOpen: "project:open", projectGetRecent: "project:get-recent", projectRemoveRecent: "project:remove-recent", + projectClearRecent: "project:clear-recent", projectGetPreferences: "project:get-preferences", projectSetPreferences: "project:set-preferences", // Shell diff --git a/src/main/lib/dialog/index.ts b/src/main/lib/dialog/index.ts index 1f29a39..5882745 100644 --- a/src/main/lib/dialog/index.ts +++ b/src/main/lib/dialog/index.ts @@ -1,5 +1,6 @@ import path from "node:path" import CONSTANTS from "@main/lib/constants" +import { attachWindowStateBroadcaster } from "@main/lib/window" import { BrowserWindow, ipcMain } from "electron" import DIALOG_CONFIG from "@/main/config/dialogs" import type { ConfirmDialogOptions, DialogOptions } from "@/main/types/dialog" @@ -62,6 +63,10 @@ export function openDialog(parent: BrowserWindow | null, options: DialogOptions) pendingDialogs.set(window.id, { options, resolve }) + // Broadcast focus and visibility changes so the dialog's title bar can + // reflect the active state consistently with the main window + attachWindowStateBroadcaster(window) + window.once("ready-to-show", () => window.show()) // A window closed without an explicit response resolves as a cancel diff --git a/src/main/lib/project/handlers.ts b/src/main/lib/project/handlers.ts index 861eeed..37f0f8c 100644 --- a/src/main/lib/project/handlers.ts +++ b/src/main/lib/project/handlers.ts @@ -1,6 +1,7 @@ import CONSTANTS from "@main/lib/constants" import { addLocalProject, + clearRecentProjects, getPreferences, getRecentProjects, openProject, @@ -19,6 +20,7 @@ export function registerProjectHandlers() { ipcMain.handle(CONSTANTS.ipc.projectOpen, (_event, dir: string) => openProject(dir)) ipcMain.handle(CONSTANTS.ipc.projectGetRecent, () => getRecentProjects()) ipcMain.handle(CONSTANTS.ipc.projectRemoveRecent, (_event, dir: string) => removeRecentProject(dir)) + ipcMain.handle(CONSTANTS.ipc.projectClearRecent, () => clearRecentProjects()) ipcMain.handle(CONSTANTS.ipc.projectGetPreferences, () => getPreferences()) ipcMain.handle(CONSTANTS.ipc.projectSetPreferences, (_event, preferences: Partial) => setPreferences(preferences), diff --git a/src/main/lib/project/service.ts b/src/main/lib/project/service.ts index f5b688d..edfaeb6 100644 --- a/src/main/lib/project/service.ts +++ b/src/main/lib/project/service.ts @@ -127,6 +127,19 @@ export async function removeRecentProject(dir: string): Promise { return updated.recentProjects } +/** + * Clears every entry from the recent projects list. + * @returns The updated recent projects list, which is always empty. + */ +export async function clearRecentProjects(): Promise { + const updated = await updateConfig(config => { + config.recentProjects = [] + return undefined + }) + + return updated.recentProjects +} + /** * Returns the current application preferences. * @returns The preferences. diff --git a/src/main/types/store.d.ts b/src/main/types/store.d.ts index f374501..a56232e 100644 --- a/src/main/types/store.d.ts +++ b/src/main/types/store.d.ts @@ -19,6 +19,7 @@ export type Project = { */ export type AppPreferences = { startupBehavior: StartupBehavior + filesPaneWidth: number } /** diff --git a/src/preload/index.ts b/src/preload/index.ts index f086330..98839aa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,7 @@ export type GitgameApi = { open: (dir: string) => Promise getRecent: () => Promise removeRecent: (dir: string) => Promise + clearRecent: () => Promise getPreferences: () => Promise setPreferences: (preferences: Partial) => Promise } diff --git a/src/preload/routes/project.ts b/src/preload/routes/project.ts index 8410b0a..13d0299 100644 --- a/src/preload/routes/project.ts +++ b/src/preload/routes/project.ts @@ -11,6 +11,7 @@ const projectApiRoutes: GitgameApi["project"] = { open: dir => ipcRenderer.invoke(CONSTANTS.ipc.projectOpen, dir), getRecent: () => ipcRenderer.invoke(CONSTANTS.ipc.projectGetRecent), removeRecent: dir => ipcRenderer.invoke(CONSTANTS.ipc.projectRemoveRecent, dir), + clearRecent: () => ipcRenderer.invoke(CONSTANTS.ipc.projectClearRecent), getPreferences: () => ipcRenderer.invoke(CONSTANTS.ipc.projectGetPreferences), setPreferences: preferences => ipcRenderer.invoke(CONSTANTS.ipc.projectSetPreferences, preferences), } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 32b526b..ed2b842 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -7,12 +7,10 @@ import StatusBar from "@renderer/components/ui/statusBar" import StatusBarField from "@renderer/components/ui/statusBar/StatusBarField" import StatusTaskField from "@renderer/components/ui/statusBar/StatusTaskField" import TitleBar from "@renderer/components/ui/TitleBar" -import TreeView from "@renderer/components/ui/treeView" +import Workspace from "@renderer/components/ui/workspace" import useMenuShortcuts from "@renderer/hooks/useMenuShortcuts" import { useCallback, useEffect, useMemo } from "react" -import { createScrollbars } from "react95" -import originalTheme from "react95/dist/themes/original" -import { createGlobalStyle, ThemeProvider } from "styled-components" +import AppRoot from "@/renderer/components/layouts/AppRoot" import MainLayout from "@/renderer/components/layouts/main" import APP_CONFIG from "@/renderer/config/app" import { buildTopLevelMenus, type MenuAction } from "@/renderer/config/menus" @@ -22,7 +20,7 @@ import { buildTopLevelMenus, type MenuAction } from "@/renderer/config/menus" * actions to the application state. */ function AppShell() { - const { currentProject, recentProjects, addLocalProject, openProject } = useProjectContext() + const { currentProject, recentProjects, addLocalProject, openProject, clearRecentProjects } = useProjectContext() /** * The main application window title (falling back to just the app name when no project is open). @@ -42,7 +40,7 @@ function AppShell() { * @param action The action selected in the menu bar. */ const handleMenuAction = useCallback( - (action: MenuAction) => { + async (action: MenuAction) => { switch (action.type) { case "project:add-local": addLocalProject() @@ -50,6 +48,18 @@ function AppShell() { case "project:open": openProject(action.path) break + case "project:clear-recent": { + const confirmed = await window.api.dialog.confirm({ + title: "Clear recent projects", + message: "Clear the entire recent projects list?", + detail: "This only forgets the entries here, your project folders on disk are left untouched.", + confirmLabel: "Clear", + isDestructive: true, + }) + + if (confirmed) clearRecentProjects() + break + } case "window:close": window.api.window.close() break @@ -61,7 +71,7 @@ function AppShell() { break } }, - [addLocalProject, openProject], + [addLocalProject, openProject, clearRecentProjects], ) // Bind the menu accelerators (Ctrl+O, Ctrl+Q, ...) to their actions @@ -78,21 +88,7 @@ function AppShell() { -
-
- -
- - {currentProject && ( -
- -
- )} -
+
{currentProject && }
{currentProject ? currentProject.path : "No project open"} @@ -102,18 +98,9 @@ function AppShell() { ) } -/** - * Applies react95's Win95 scrollbar styling globally. - */ -const GlobalScrollbars = createGlobalStyle` - ${createScrollbars()} -` - export default function App() { return ( - - - + @@ -121,6 +108,6 @@ export default function App() { - + ) } diff --git a/src/renderer/DialogApp.tsx b/src/renderer/DialogApp.tsx index 17af39b..e21d481 100644 --- a/src/renderer/DialogApp.tsx +++ b/src/renderer/DialogApp.tsx @@ -1,51 +1,53 @@ -import { useEffect, useState } from "react" -import { Button, Window, WindowContent, WindowHeader } from "react95" -import originalTheme from "react95/dist/themes/original" -import { ThemeProvider } from "styled-components" +import AppRoot from "@renderer/components/layouts/AppRoot" +import MainLayout from "@renderer/components/layouts/main" +import TitleBar from "@renderer/components/ui/TitleBar" +import { useCallback, useEffect, useState } from "react" +import { Button } from "react95" import type { DialogOptions } from "@/main/types/dialog" export default function DialogApp() { const [options, setOptions] = useState(null) + /** + * Responds to the dialog with a confirmation, closing the window. + */ + const handleConfirm = useCallback(() => { + window.api.dialog.respond(true) + }, []) + + /** + * Responds to the dialog with a cancellation, closing the window. + */ + const handleCancel = useCallback(() => { + window.api.dialog.respond(false) + }, []) + // Fetch the options for this dialog window once, on mount useEffect(() => { window.api.dialog.getOptions().then(setOptions) }, []) - // Respond to the confirm/cancel keyboard shortcuts + // Confirm on Enter, cancel on Escape useEffect(() => { /** * Confirms on Enter, cancels on Escape. * @param event The keyboard event. */ const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") window.api.dialog.respond(false) - if (event.key === "Enter") window.api.dialog.respond(true) + if (event.key === "Escape") handleCancel() + if (event.key === "Enter") handleConfirm() } document.addEventListener("keydown", handleKeyDown) return () => document.removeEventListener("keydown", handleKeyDown) - }, []) - - const isConfirm = options?.variant === "confirm" + }, [handleCancel, handleConfirm]) return ( - - - - {options?.title} - - - + + + - +

{options?.message}

@@ -53,18 +55,16 @@ export default function DialogApp() {
- {isConfirm && ( - + {options?.variant === "confirm" && ( + )} -
- - - +
+
+
) } diff --git a/src/renderer/components/contexts/Project.tsx b/src/renderer/components/contexts/Project.tsx index d02953e..eeabe26 100644 --- a/src/renderer/components/contexts/Project.tsx +++ b/src/renderer/components/contexts/Project.tsx @@ -14,6 +14,7 @@ export type ProjectContextType = { addLocalProject: () => Promise openProject: (dir: string) => Promise removeRecentProject: (dir: string) => Promise + clearRecentProjects: () => Promise closeProject: () => void } @@ -93,6 +94,13 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { setCurrentProject(current => (current?.path === dir ? null : current)) }, []) + /** + * Clears every entry from the recent projects list. + */ + const clearRecentProjects = useCallback(async () => { + setRecentProjects(await window.api.project.clearRecent()) + }, []) + /** * Closes the current project without affecting the recent projects list. */ @@ -135,6 +143,7 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { addLocalProject, openProject, removeRecentProject, + clearRecentProjects, closeProject, }} > diff --git a/src/renderer/components/layouts/AppRoot.tsx b/src/renderer/components/layouts/AppRoot.tsx new file mode 100644 index 0000000..ee5ac47 --- /dev/null +++ b/src/renderer/components/layouts/AppRoot.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react" +import { createScrollbars } from "react95" +import originalTheme from "react95/dist/themes/original" +import { createGlobalStyle, ThemeProvider } from "styled-components" + +/** + * Applies react95's Win95 scrollbar styling globally. + */ +const GlobalScrollbars = createGlobalStyle` + ${createScrollbars()} +` + +type AppRootProps = { + children: ReactNode +} + +export default function AppRoot({ children }: AppRootProps) { + return ( + + + + {children} + + ) +} diff --git a/src/renderer/components/ui/TitleBar.tsx b/src/renderer/components/ui/TitleBar.tsx index 37ad666..2c70049 100644 --- a/src/renderer/components/ui/TitleBar.tsx +++ b/src/renderer/components/ui/TitleBar.tsx @@ -6,10 +6,12 @@ import { Button } from "react95" type TitleBarProps = { title: string - icon: string + icon?: string + mode?: "full" | "dialog" + onClose?: () => void } -export default function TitleBar({ title, icon }: TitleBarProps) { +export default function TitleBar({ title, icon, mode = "full", onClose }: TitleBarProps) { const states = useWindowStates() return ( @@ -29,7 +31,7 @@ export default function TitleBar({ title, icon }: TitleBarProps) { onDoubleClick={() => window.api.window.toggleMaximize()} >
- {!window.api.platform.isMacOS && ( + {icon && !window.api.platform.isMacOS && ( )} @@ -40,29 +42,34 @@ export default function TitleBar({ title, icon }: TitleBarProps) { {!window.api.platform.isMacOS && (
- - + {mode === "full" && ( + <> + + + + )} + + + + + + + + + + + + + + + +
+ {selectedNode ? ( + +
+
+ Name: + {selectedNode.name} +
+ +
+ Path: + {selectedNode.path} +
+ +
+ Lockable: + {selectedNode.isLockable ? "Yes" : "No"} +
+ + {selectedNode.type === "file" && selectedFileLock && ( + <> +
+ Locked by: + + {selectedFileLock.isMine + ? `${selectedFileLock.owner} (you)` + : selectedFileLock.owner} + +
+ + {lockedAtLabel && ( +
+ Locked at: + {lockedAtLabel} +
+ )} + + )} + + {selectedNode.type === "folder" && ( + <> +
+ Lockable files: + {lockablePaths.length} +
+ +
+ Locked by you: + {minePaths.length} +
+ +
+ Locked by others: + {othersPaths.length} +
+ + )} +
+
+ ) : ( + +
+
+ Name: + {currentProject?.name ?? "No project open"} +
+ + {currentProject?.path && ( +
+ Path: + {currentProject.path} +
+ )} + +
+ Locked by you: + {overallCounts.mine} +
+ +
+ Locked by others: + {overallCounts.others} +
+
+ +
+ Select a file or folder in the tree to see its lock details. +
+
+ )} +
+
+
+ ) +} diff --git a/src/renderer/components/ui/workspace/FilesPane.tsx b/src/renderer/components/ui/workspace/FilesPane.tsx new file mode 100644 index 0000000..ff63f55 --- /dev/null +++ b/src/renderer/components/ui/workspace/FilesPane.tsx @@ -0,0 +1,41 @@ +import { cn } from "@cybearl/cypack/frontend" +import TreeView from "@renderer/components/ui/treeView" +import { type ChangeEvent, type CSSProperties, useCallback, useState } from "react" +import { TextInput } from "react95" + +type FilesPaneProps = { + selected: string | undefined + onSelect: (id: string) => void + className?: string + style?: CSSProperties +} + +export default function FilesPane({ selected, onSelect, className, style }: FilesPaneProps) { + const [query, setQuery] = useState("") + + /** + * Updates the query as the user types in the search input. + * @param event The change event from the input. + */ + const handleQueryChange = useCallback((event: ChangeEvent) => { + setQuery(event.target.value) + }, []) + + return ( +
+
+ +
+ +
+ +
+
+ ) +} diff --git a/src/renderer/components/ui/workspace/index.tsx b/src/renderer/components/ui/workspace/index.tsx new file mode 100644 index 0000000..81f3905 --- /dev/null +++ b/src/renderer/components/ui/workspace/index.tsx @@ -0,0 +1,85 @@ +import { useTreeContext } from "@renderer/components/contexts/Tree" +import DetailsPane from "@renderer/components/ui/workspace/DetailsPane" +import FilesPane from "@renderer/components/ui/workspace/FilesPane" +import useResizablePaneWidth from "@renderer/hooks/useResizablePaneWidth" +import { useCallback, useEffect, useMemo, useState } from "react" +import WORKSPACE_CONFIG from "@/renderer/config/workspace" +import { findNodeByPath } from "@/renderer/lib/utils/treeView" + +export default function Workspace() { + const [selectedPath, setSelectedPath] = useState(undefined) + + const { fileTree } = useTreeContext() + + /** + * Persists the final files pane width to the app preferences, fired when a + * resize drag ends. + * @param finalWidth The width to persist, in pixels. + */ + const persistWidth = useCallback((finalWidth: number) => { + window.api.project.setPreferences({ filesPaneWidth: finalWidth }) + }, []) + + const { width, setWidth, handleDragStart } = useResizablePaneWidth({ + initialWidth: WORKSPACE_CONFIG.filesPaneDefaultWidth, + minWidth: WORKSPACE_CONFIG.filesPaneMinWidth, + maxWidth: WORKSPACE_CONFIG.filesPaneMaxWidth, + onCommit: persistWidth, + }) + + // Load the persisted files pane width once on mount, clamped by the hook + useEffect(() => { + let cancelled = false + + window.api.project.getPreferences().then(preferences => { + if (cancelled) return + setWidth(preferences.filesPaneWidth) + }) + + return () => { + cancelled = true + } + }, [setWidth]) + + /** + * The file tree node currently selected in the files pane, resolved from + * the tracked path so it stays in sync with tree refreshes. + */ + const selectedNode = useMemo( + () => (selectedPath ? findNodeByPath(fileTree, selectedPath) : undefined), + [fileTree, selectedPath], + ) + + /** + * The inline style applied to the files pane so its width tracks the + * resize hook. + */ + const filesPaneStyle = useMemo(() => ({ width }), [width]) + + return ( +
+
+ +
+ +
+ + +
+
+
+ + +
+
+ ) +} diff --git a/src/renderer/config/menus.ts b/src/renderer/config/menus.ts index 20c241a..67356fc 100644 --- a/src/renderer/config/menus.ts +++ b/src/renderer/config/menus.ts @@ -6,6 +6,7 @@ import type { Project } from "@/main/types/store" export type MenuAction = | { type: "project:add-local" } | { type: "project:open"; path: string } + | { type: "project:clear-recent" } | { type: "window:close" } | { type: "view:reload" } | { type: "shell:open-external"; url: string } @@ -65,7 +66,7 @@ function buildRecentProjectsItems(recentProjects: Project[], currentProject: Pro ] } - return recentProjects.map(project => ({ + const projectItems: TopLevelMenuEntry[] = recentProjects.map(project => ({ type: "item", label: currentProject?.name === project.name ? `${project.name} (current)` : project.name, action: { @@ -73,6 +74,16 @@ function buildRecentProjectsItems(recentProjects: Project[], currentProject: Pro path: project.path, }, })) + + return [ + ...projectItems, + { type: "separator" }, + { + type: "item", + label: "Clear Recent Projects", + action: { type: "project:clear-recent" }, + }, + ] } /** diff --git a/src/renderer/config/workspace.ts b/src/renderer/config/workspace.ts new file mode 100644 index 0000000..a9bcfbb --- /dev/null +++ b/src/renderer/config/workspace.ts @@ -0,0 +1,21 @@ +/** + * The configuration for the workspace layout. + */ +const WORKSPACE_CONFIG = { + /** + * The minimum allowed width for the files pane, in pixels. + */ + filesPaneMinWidth: 200, + + /** + * The maximum allowed width for the files pane, in pixels. + */ + filesPaneMaxWidth: 800, + + /** + * The default width for the files pane when no preference is stored, in pixels. + */ + filesPaneDefaultWidth: 320, +} + +export default WORKSPACE_CONFIG diff --git a/src/renderer/hooks/useResizablePaneWidth.tsx b/src/renderer/hooks/useResizablePaneWidth.tsx new file mode 100644 index 0000000..f5f6348 --- /dev/null +++ b/src/renderer/hooks/useResizablePaneWidth.tsx @@ -0,0 +1,113 @@ +import { type MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef, useState } from "react" + +/** + * The options for the `useResizablePaneWidth` hook. + */ +type UseResizablePaneWidthOptions = { + initialWidth: number + minWidth: number + maxWidth: number + onCommit?: (width: number) => void +} + +/** + * The value returned by the `useResizablePaneWidth` hook. + */ +type UseResizablePaneWidthResult = { + width: number + setWidth: (next: number) => void + handleDragStart: (event: ReactMouseEvent) => void +} + +/** + * Manages a resizable pane's width driven by mouse drag on a splitter element. + * @param options The initial width, clamp bounds, and commit callback. + * @returns The current width, a clamping setter, and the drag-start handler. + */ +export default function useResizablePaneWidth({ + initialWidth, + minWidth, + maxWidth, + onCommit, +}: UseResizablePaneWidthOptions): UseResizablePaneWidthResult { + const [width, setInternalWidth] = useState(initialWidth) + + /** + * A ref mirroring the latest width so drag handlers always read the current + * value without going stale. + */ + const widthRef = useRef(initialWidth) + + useEffect(() => { + widthRef.current = width + }, [width]) + + /** + * A ref to the commit callback so its identity does not force the drag + * handler to be recreated on every render. + */ + const onCommitRef = useRef(onCommit) + + useEffect(() => { + onCommitRef.current = onCommit + }, [onCommit]) + + /** + * Sets the pane width, clamping the given value into the configured range. + * @param next The requested width, in pixels. + */ + const setWidth = useCallback( + (next: number) => { + setInternalWidth(Math.max(minWidth, Math.min(maxWidth, next))) + }, + [minWidth, maxWidth], + ) + + /** + * Starts a resize drag from the splitter, wiring document-level move and up + * listeners for the drag's duration and persisting the final width on end. + * @param event The pointer-down event on the splitter. + */ + const handleDragStart = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault() + + const startX = event.clientX + const startWidth = widthRef.current + const previousBodyCursor = document.body.style.cursor + const previousBodyUserSelect = document.body.style.userSelect + + document.body.style.cursor = "col-resize" + document.body.style.userSelect = "none" + + /** + * Updates the pane width as the pointer moves during the drag. + * @param moveEvent The pointer-move event. + */ + const handleMove = (moveEvent: MouseEvent) => { + const next = Math.max(minWidth, Math.min(maxWidth, startWidth + (moveEvent.clientX - startX))) + setInternalWidth(next) + } + + /** + * Ends the drag, removing the document listeners, restoring the + * body cursor, and firing the commit callback with the final width. + */ + const handleUp = () => { + document.removeEventListener("mousemove", handleMove) + document.removeEventListener("mouseup", handleUp) + + document.body.style.cursor = previousBodyCursor + document.body.style.userSelect = previousBodyUserSelect + + onCommitRef.current?.(widthRef.current) + } + + document.addEventListener("mousemove", handleMove) + document.addEventListener("mouseup", handleUp) + }, + [minWidth, maxWidth], + ) + + return { width, setWidth, handleDragStart } +} diff --git a/src/renderer/hooks/useWindowStates.tsx b/src/renderer/hooks/useWindowStates.tsx index 70a7040..259a619 100644 --- a/src/renderer/hooks/useWindowStates.tsx +++ b/src/renderer/hooks/useWindowStates.tsx @@ -4,10 +4,21 @@ import type { WindowState } from "@/preload" export default function useWindowStates() { const [states, setStates] = useState() - // Subscribe to window state changes + // Fetch the initial state on mount and subscribe to subsequent changes so + // the first render already reflects the actual window focus useEffect(() => { + let cancelled = false + + window.api.window.getState().then(state => { + if (!cancelled) setStates(state) + }) + const unsubscribe = window.api.window.onStateChange(setStates) - return () => unsubscribe() + + return () => { + cancelled = true + unsubscribe() + } }, []) return states diff --git a/src/renderer/lib/utils/lockStates.ts b/src/renderer/lib/utils/lockStates.ts index 89aa9c3..6796212 100644 --- a/src/renderer/lib/utils/lockStates.ts +++ b/src/renderer/lib/utils/lockStates.ts @@ -126,6 +126,33 @@ export function computeLockOwners( return owners } +/** + * Collects the paths of every lockable file within a node's subtree, matching + * the set of files a lock action expands the node to. + * @param node The node whose subtree to scan. + * @returns The lockable file paths. + */ +export function collectLockablePaths(node: FileTreeNode): string[] { + const paths: string[] = [] + + /** + * Appends the node's path when it is a lockable file, then recurses. + * @param current The node to visit. + */ + const visit = (current: FileTreeNode) => { + if (current.type === "file") { + if (current.isLockable) paths.push(current.path) + return + } + + for (const child of current.children ?? []) visit(child) + } + + visit(node) + + return paths +} + /** * Collects the paths of every locked file within a node's subtree, filtered by * whether the current user owns the lock. diff --git a/src/renderer/lib/utils/treeView.tsx b/src/renderer/lib/utils/treeView.tsx index 4ae4706..7e7a332 100644 --- a/src/renderer/lib/utils/treeView.tsx +++ b/src/renderer/lib/utils/treeView.tsx @@ -62,7 +62,7 @@ export function renderLockIcon( : buildFolderTooltip(lockState, owners) return ( - + Locked { + const selfMatches = node.name.toLowerCase().includes(needle) + + if (node.type === "file") return selfMatches ? node : null + + const filteredChildren: FileTreeNode[] = [] + + for (const child of node.children ?? []) { + const kept = visit(child) + if (kept) filteredChildren.push(kept) + } + + if (filteredChildren.length === 0 && !selfMatches) return null + if (filteredChildren.length > 0) matchAncestorPaths.push(node.path) + + return { + ...node, + children: filteredChildren.length > 0 ? filteredChildren : undefined, + } + } + + const tree: FileTreeNode[] = [] + + for (const node of nodes) { + const kept = visit(node) + if (kept) tree.push(kept) + } + + return { + tree, + matchAncestorPaths, + } +} + +/** + * Locates a file tree node by its repository-relative path, walking the tree + * depth-first from the given roots. + * @param roots The root-level file tree nodes. + * @param path The repository-relative path to locate. + * @returns The matching node, or `undefined` if no node has that path. + */ +export function findNodeByPath(roots: FileTreeNode[], path: string): FileTreeNode | undefined { + for (const node of roots) { + if (node.path === path) return node + + if (node.children) { + const found = findNodeByPath(node.children, path) + if (found) return found + } + } + + return undefined +} + /** * Resolves the file tree node under a right-click by mapping the DOM position of * the target tree item back through the tree by sibling index at each level. diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index af5658c..47370c0 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -1,213 +1,12 @@ @import "tailwindcss"; @import "./tailwind-theme.css"; @import "./fonts.css"; +@import "./react95.css"; @layer base { - /* - * Inlined from `react95`'s `styleReset` so the renderer no longer needs a - * styled-components `createGlobalStyle` wrapper just to inject these rules. - * Note: should be kept in sync with `node_modules/react95/dist/common/styleReset.mjs`. - */ - html, - body, - div, - span, - applet, - object, - iframe, - h1, - h2, - h3, - h4, - h5, - h6, - p, - blockquote, - pre, - a, - abbr, - acronym, - address, - big, - cite, - code, - del, - dfn, - em, - img, - ins, - kbd, - q, - s, - samp, - small, - strike, - strong, - sub, - sup, - tt, - var, - b, - u, - i, - center, - dl, - dt, - dd, - ol, - ul, - li, - fieldset, - form, - label, - legend, - table, - caption, - tbody, - tfoot, - thead, - tr, - th, - td, - article, - aside, - canvas, - details, - embed, - figure, - figcaption, - footer, - header, - hgroup, - menu, - nav, - output, - ruby, - section, - summary, - time, - mark, - audio, - video { - margin: 0; - padding: 0; - vertical-align: baseline; - border: 0; - font: inherit; - } - - article, - aside, - details, - figcaption, - figure, - footer, - header, - hgroup, - menu, - nav, - section { - display: block; - } - - * { - -webkit-font-smoothing: none; - /* biome-ignore lint/correctness/noUnknownProperty: still available depending on the environment */ - font-smooth: never; - /* Deprecated but harmless */ - text-rendering: geometricPrecision; - } - - body { - margin: 0; - padding: 0; - font-family: var(--font-sans); - line-height: 1.5; - background-color: #c0c0c0; - } - - ol, - ul, - li { - list-style: none; - } - - blockquote, - q { - quotes: none; - } - - blockquote:before, - blockquote:after, - q:before, - q:after { - content: none; - } - - table { - border-collapse: collapse; - border-spacing: 0; - } - - a { - color: inherit; - text-decoration: none; - } - - button { - outline: none; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); - } - - code { - font-family: - source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; - } - - input[type="number"]::-webkit-outer-spin-button, - input[type="number"]::-webkit-inner-spin-button { - margin: 0; - -webkit-appearance: none; - appearance: none; - } - - input[type="number"] { - -moz-appearance: textfield; - appearance: textfield; - } - html, body { width: 100%; height: 100%; } } - -/* - * Hiding scrollbar buttons since the icons are invisible there for some reason. - */ -::-webkit-scrollbar-button { - /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ - display: none !important; -} - -/** - * Fixes the vertical positioning of the summary element's pseudo-element `+`/`-` - * glyph. Restoring `content-box` reinstates the intended 13x13 toggle after - * Tailwind's `border-box` preflight, and `padding-top` nudges the glyph down - * inside the box so it sits optically centered rather than hugging the top - * edge of the 9px content area. Tune the padding if the glyph needs more shift. - */ -details > summary::before { - box-sizing: content-box; - left: 50%; - /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ - padding-top: 1px !important; - /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ - padding-right: 1px !important; - /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ - padding-bottom: 1px !important; - /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ - padding-left: 2px !important; - transform: translateX(-10%); -} diff --git a/src/renderer/styles/react95.css b/src/renderer/styles/react95.css new file mode 100644 index 0000000..f4663cc --- /dev/null +++ b/src/renderer/styles/react95.css @@ -0,0 +1,206 @@ +@layer base { + /* + * Inlined from `react95`'s `styleReset` so the renderer no longer needs a + * styled-components `createGlobalStyle` wrapper just to inject these rules. + * Note: should be kept in sync with `node_modules/react95/dist/common/styleReset.mjs`. + */ + html, + body, + div, + span, + applet, + object, + iframe, + h1, + h2, + h3, + h4, + h5, + h6, + p, + blockquote, + pre, + a, + abbr, + acronym, + address, + big, + cite, + code, + del, + dfn, + em, + img, + ins, + kbd, + q, + s, + samp, + small, + strike, + strong, + sub, + sup, + tt, + var, + b, + u, + i, + center, + dl, + dt, + dd, + ol, + ul, + li, + fieldset, + form, + label, + legend, + table, + caption, + tbody, + tfoot, + thead, + tr, + th, + td, + article, + aside, + canvas, + details, + embed, + figure, + figcaption, + footer, + header, + hgroup, + menu, + nav, + output, + ruby, + section, + summary, + time, + mark, + audio, + video { + margin: 0; + padding: 0; + vertical-align: baseline; + border: 0; + font: inherit; + } + + article, + aside, + details, + figcaption, + figure, + footer, + header, + hgroup, + menu, + nav, + section { + display: block; + } + + * { + -webkit-font-smoothing: none; + /* biome-ignore lint/correctness/noUnknownProperty: still available depending on the environment */ + font-smooth: never; + /* Deprecated but harmless */ + text-rendering: geometricPrecision; + } + + body { + margin: 0; + padding: 0; + font-family: var(--font-sans); + line-height: 1.5; + background-color: #c0c0c0; + } + + ol, + ul, + li { + list-style: none; + } + + blockquote, + q { + quotes: none; + } + + blockquote:before, + blockquote:after, + q:before, + q:after { + content: none; + } + + table { + border-collapse: collapse; + border-spacing: 0; + } + + a { + color: inherit; + text-decoration: none; + } + + button { + outline: none; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + } + + code { + font-family: + source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; + } + + input[type="number"]::-webkit-outer-spin-button, + input[type="number"]::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; + } + + input[type="number"] { + -moz-appearance: textfield; + appearance: textfield; + } +} + +/** + * Fixes the vertical positioning of the summary element's pseudo-element `+`/`-` + * glyph. Restoring `content-box` reinstates the intended 13x13 toggle after + * Tailwind's `border-box` preflight, and `padding-top` nudges the glyph down + * inside the box so it sits optically centered rather than hugging the top + * edge of the 9px content area. Tune the padding if the glyph needs more shift. + */ +details > summary::before { + box-sizing: content-box; + left: 50%; + /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ + padding-top: 1px !important; + /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ + padding-right: 1px !important; + /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ + padding-bottom: 1px !important; + /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ + padding-left: 2px !important; + transform: translateX(-10%); +} + +/** + * Draws the classic Win95 sizer-grip pattern in the ScrollView corner between + * the vertical and horizontal scrollbars. + */ +.tree-scrollview > div::-webkit-scrollbar-corner { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' shape-rendering='crispEdges'%3E%3Cg fill='%23ffffff'%3E%3Crect x='10' y='4' width='2' height='2'/%3E%3Crect x='6' y='8' width='2' height='2'/%3E%3Crect x='10' y='8' width='2' height='2'/%3E%3Crect x='2' y='12' width='2' height='2'/%3E%3Crect x='6' y='12' width='2' height='2'/%3E%3Crect x='10' y='12' width='2' height='2'/%3E%3C/g%3E%3Cg fill='%23808080'%3E%3Crect x='12' y='6' width='2' height='2'/%3E%3Crect x='8' y='10' width='2' height='2'/%3E%3Crect x='12' y='10' width='2' height='2'/%3E%3Crect x='4' y='14' width='2' height='2'/%3E%3Crect x='8' y='14' width='2' height='2'/%3E%3Crect x='12' y='14' width='2' height='2'/%3E%3C/g%3E%3C/svg%3E"); + background-position: bottom right; + background-size: 16px 16px; + background-repeat: no-repeat; +} diff --git a/src/renderer/styles/tailwind-theme.css b/src/renderer/styles/tailwind-theme.css index b1743d3..f05eaec 100644 --- a/src/renderer/styles/tailwind-theme.css +++ b/src/renderer/styles/tailwind-theme.css @@ -1,7 +1,7 @@ @theme { /* Overall colors */ --color-primary: #ffffff; - --color-secondary: #c0c0c0; + --color-secondary: #c6c6c6; --color-inactive: #808080; --color-active: #000080;