From 73757c4fb92dd7acb9b0b2c517d7be60f45c7b0a Mon Sep 17 00:00:00 2001 From: yoratoni Date: Tue, 7 Jul 2026 20:54:10 +0200 Subject: [PATCH 1/5] fix: correcting the number of files appearing in the lock message of the status bar --- src/renderer/components/ui/treeView/index.tsx | 11 ++++++-- src/renderer/lib/utils/lockStates.ts | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/ui/treeView/index.tsx b/src/renderer/components/ui/treeView/index.tsx index 8b024da..6476b14 100644 --- a/src/renderer/components/ui/treeView/index.tsx +++ b/src/renderer/components/ui/treeView/index.tsx @@ -3,7 +3,12 @@ import ContextMenu from "@renderer/components/ui/treeView/ContextMenu" import { type MouseEvent, useCallback, useMemo, useRef, useState } from "react" import { TreeView as React95TreeView, ScrollView } from "react95" import type { FileTreeNode } from "@/main/types/tree" -import { collectLockedPaths, computeLockOwners, computeLockStates } from "@/renderer/lib/utils/lockStates" +import { + collectLockablePaths, + collectLockedPaths, + computeLockOwners, + computeLockStates, +} from "@/renderer/lib/utils/lockStates" import { buildTree, reportLockFailures, resolveNode } from "@/renderer/lib/utils/treeView" /** @@ -14,6 +19,7 @@ type MenuState = { y: number node: FileTreeNode canLock: boolean + lockablePaths: string[] minePaths: string[] othersPaths: string[] } @@ -62,6 +68,7 @@ export default function TreeView() { y: event.clientY, node, canLock: (lockStates.get(node.path) ?? "unlockable") !== "locked", + lockablePaths: collectLockablePaths(node), minePaths: collectLockedPaths(node, locksByPath, true), othersPaths: collectLockedPaths(node, locksByPath, false), }) @@ -81,7 +88,7 @@ export default function TreeView() { if (!menu) return dismissMenu() - reportLockFailures(await lock([menu.node.path])) + reportLockFailures(await lock(menu.lockablePaths)) }, [menu, lock, dismissMenu]) /** 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. From c9bac48d4e54439cf7e6f6691866b2998cd1ac5a Mon Sep 17 00:00:00 2001 From: yoratoni Date: Tue, 7 Jul 2026 22:57:49 +0200 Subject: [PATCH 2/5] fix: showing scrollbar buttons again --- src/renderer/styles/globals.css | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index af5658c..b4885de 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -3,6 +3,7 @@ @import "./fonts.css"; @layer base { + /* * Inlined from `react95`'s `styleReset` so the renderer no longer needs a * styled-components `createGlobalStyle` wrapper just to inject these rules. @@ -183,14 +184,6 @@ } } -/* - * 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 @@ -210,4 +203,4 @@ details > summary::before { /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ padding-left: 2px !important; transform: translateX(-10%); -} +} \ No newline at end of file From 37ea24136dc42aa05acf1a6516bf2470375840d9 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Tue, 7 Jul 2026 23:56:51 +0200 Subject: [PATCH 3/5] feat: tree view files panel --- src/main/config/app.ts | 1 + src/main/types/store.d.ts | 1 + src/renderer/App.tsx | 18 +- src/renderer/components/ui/treeView/index.tsx | 11 +- .../components/ui/workspace/DetailsPane.tsx | 260 ++++++++++++++++++ .../components/ui/workspace/FilesPane.tsx | 18 ++ .../components/ui/workspace/index.tsx | 85 ++++++ src/renderer/config/workspace.ts | 21 ++ src/renderer/hooks/useResizablePaneWidth.tsx | 113 ++++++++ src/renderer/lib/utils/treeView.tsx | 22 +- src/renderer/styles/globals.css | 5 +- src/renderer/styles/tailwind-theme.css | 2 +- 12 files changed, 533 insertions(+), 24 deletions(-) create mode 100644 src/renderer/components/ui/workspace/DetailsPane.tsx create mode 100644 src/renderer/components/ui/workspace/FilesPane.tsx create mode 100644 src/renderer/components/ui/workspace/index.tsx create mode 100644 src/renderer/config/workspace.ts create mode 100644 src/renderer/hooks/useResizablePaneWidth.tsx 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/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/renderer/App.tsx b/src/renderer/App.tsx index 32b526b..6891512 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -7,7 +7,7 @@ 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" @@ -78,21 +78,7 @@ function AppShell() { -
-
- -
- - {currentProject && ( -
- -
- )} -
+
{currentProject && }
{currentProject ? currentProject.path : "No project open"} diff --git a/src/renderer/components/ui/treeView/index.tsx b/src/renderer/components/ui/treeView/index.tsx index 6476b14..2c08870 100644 --- a/src/renderer/components/ui/treeView/index.tsx +++ b/src/renderer/components/ui/treeView/index.tsx @@ -24,13 +24,17 @@ type MenuState = { othersPaths: string[] } -export default function TreeView() { +type TreeViewProps = { + selected: string | undefined + onSelect: (id: string) => void +} + +export default function TreeView({ selected, onSelect }: TreeViewProps) { const { fileTree, locksByPath, lock, unlock } = useTreeContext() const containerRef = useRef(null) const [expanded, setExpanded] = useState([]) - const [selected, setSelected] = useState() const [menu, setMenu] = useState(null) /** @@ -117,6 +121,7 @@ export default function TreeView() { confirmLabel: "Force unlock", isDestructive: true, }) + if (!confirmed) return reportLockFailures(await unlock(othersPaths, true)) @@ -129,7 +134,7 @@ export default function TreeView() { tree={tree} expanded={expanded} selected={selected} - onNodeSelect={(_, id) => setSelected(id)} + onNodeSelect={(_, id) => onSelect(id)} onNodeToggle={(_, ids) => setExpanded(ids)} /> diff --git a/src/renderer/components/ui/workspace/DetailsPane.tsx b/src/renderer/components/ui/workspace/DetailsPane.tsx new file mode 100644 index 0000000..a0f2d32 --- /dev/null +++ b/src/renderer/components/ui/workspace/DetailsPane.tsx @@ -0,0 +1,260 @@ +import { cn } from "@cybearl/cypack/frontend" +import lockIcon from "@react95-icons/Lock_16x16_4.png" +import refreshIcon from "@react95-icons/Refresh_16x16_4.png" +import { useProjectContext } from "@renderer/components/contexts/Project" +import { useTreeContext } from "@renderer/components/contexts/Tree" +import Tooltip from "@renderer/components/ui/Tooltip" +import { useCallback, useMemo } from "react" +import { Button, GroupBox, ScrollView, Separator, Toolbar } from "react95" +import type { FileTreeNode } from "@/main/types/tree" +import { collectLockablePaths, collectLockedPaths } from "@/renderer/lib/utils/lockStates" +import { reportLockFailures } from "@/renderer/lib/utils/treeView" + +type DetailsPaneProps = { + selectedNode: FileTreeNode | undefined + className?: string +} + +export default function DetailsPane({ selectedNode, className }: DetailsPaneProps) { + const { currentProject } = useProjectContext() + const { locksByPath, refresh, lock, unlock } = useTreeContext() + + /** + * The lockable file paths within the selected node's subtree, empty when + * nothing is selected. + */ + const lockablePaths = useMemo(() => (selectedNode ? collectLockablePaths(selectedNode) : []), [selectedNode]) + + /** + * The paths within the selected subtree locked by the current user. + */ + const minePaths = useMemo( + () => (selectedNode ? collectLockedPaths(selectedNode, locksByPath, true) : []), + [selectedNode, locksByPath], + ) + + /** + * The paths within the selected subtree locked by other users. + */ + const othersPaths = useMemo( + () => (selectedNode ? collectLockedPaths(selectedNode, locksByPath, false) : []), + [selectedNode, locksByPath], + ) + + /** + * The overall lock counts across the whole project, split between the + * current user and others. + */ + const overallCounts = useMemo(() => { + let mine = 0 + let others = 0 + + for (const entry of locksByPath.values()) { + if (entry.isMine) mine++ + else others++ + } + + return { mine, others } + }, [locksByPath]) + + /** + * The active lock on the selected node when it is a locked file, or + * `undefined` for folders and unlocked files. + */ + const selectedFileLock = useMemo(() => { + if (selectedNode?.type !== "file") return undefined + return locksByPath.get(selectedNode.path) + }, [selectedNode, locksByPath]) + + /** + * The formatted local timestamp for the selected file's lock, if any. + */ + const lockedAtLabel = useMemo(() => { + if (!selectedFileLock?.lockedAt) return null + return new Date(selectedFileLock.lockedAt).toLocaleString() + }, [selectedFileLock]) + + /** + * Refreshes the file tree and its lock state. + */ + const handleRefresh = useCallback(() => { + refresh() + }, [refresh]) + + /** + * Locks every lockable file within the selected subtree. + */ + const handleLock = useCallback(async () => { + reportLockFailures(await lock(lockablePaths)) + }, [lock, lockablePaths]) + + /** + * Unlocks every file within the selected subtree locked by the current user. + */ + const handleUnlock = useCallback(async () => { + reportLockFailures(await unlock(minePaths, false)) + }, [unlock, minePaths]) + + /** + * Force-unlocks every file within the selected subtree locked by other + * users, after native confirmation. + */ + const handleForceUnlock = useCallback(async () => { + const confirmed = await window.api.dialog.confirm({ + title: "Force unlock", + message: `Force unlock ${othersPaths.length} file${othersPaths.length === 1 ? "" : "s"} locked by other users?`, + detail: "Forcing may discard work the lock owner has not pushed yet.", + confirmLabel: "Force unlock", + isDestructive: true, + }) + + if (!confirmed) return + + reportLockFailures(await unlock(othersPaths, true)) + }, [unlock, othersPaths]) + + return ( +
+ + + + + + + + + + + + + + + + + +
+ {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..53f5227 --- /dev/null +++ b/src/renderer/components/ui/workspace/FilesPane.tsx @@ -0,0 +1,18 @@ +import { cn } from "@cybearl/cypack/frontend" +import TreeView from "@renderer/components/ui/treeView" +import type { CSSProperties } from "react" + +type FilesPaneProps = { + selected: string | undefined + onSelect: (id: string) => void + className?: string + style?: CSSProperties +} + +export default function FilesPane({ selected, onSelect, className, style }: FilesPaneProps) { + 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..e54af86 --- /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/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/lib/utils/treeView.tsx b/src/renderer/lib/utils/treeView.tsx index 4ae4706..d6fa439 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 summary::before { /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ padding-left: 2px !important; transform: translateX(-10%); -} \ No newline at end of file +} 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; From 00de34376f424f43be51581d1e03571d7f113e0a Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 8 Jul 2026 01:00:03 +0200 Subject: [PATCH 4/5] feat: clear recent projects + fixed dialogs styling --- src/main/config/dialogs.ts | 2 +- src/main/lib/constants.ts | 1 + src/main/lib/dialog/index.ts | 5 ++ src/main/lib/project/handlers.ts | 2 + src/main/lib/project/service.ts | 13 ++++ src/preload/index.ts | 1 + src/preload/routes/project.ts | 1 + src/renderer/App.tsx | 35 +++++----- src/renderer/DialogApp.tsx | 68 ++++++++++---------- src/renderer/components/contexts/Project.tsx | 9 +++ src/renderer/components/layouts/AppRoot.tsx | 25 +++++++ src/renderer/components/ui/TitleBar.tsx | 51 ++++++++------- src/renderer/config/menus.ts | 13 +++- src/renderer/hooks/useWindowStates.tsx | 15 ++++- 14 files changed, 164 insertions(+), 77 deletions(-) create mode 100644 src/renderer/components/layouts/AppRoot.tsx 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/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 6891512..ed2b842 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -10,9 +10,7 @@ import TitleBar from "@renderer/components/ui/TitleBar" 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 @@ -88,18 +98,9 @@ function AppShell() { ) } -/** - * Applies react95's Win95 scrollbar styling globally. - */ -const GlobalScrollbars = createGlobalStyle` - ${createScrollbars()} -` - export default function App() { return ( - - - + @@ -107,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" && ( + <> + + + + )} +