Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
// CSpell
"cSpell.words": [
"asar",
"asynciterable"
"asynciterable",
"scrollview"
],
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
Expand Down
1 change: 1 addition & 0 deletions src/main/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const DEFAULT_APP_CONFIG: AppConfig = {
version: STORE_CONFIG.configVersion,
preferences: {
startupBehavior: "reopen-last",
filesPaneWidth: 320,
},
recentProjects: [],
lfsLockCache: {},
Expand Down
2 changes: 1 addition & 1 deletion src/main/config/dialogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
const DIALOG_CONFIG = {
sizes: {
confirm: { width: 420, height: 210 },
confirm: { width: 420, height: 230 },
error: { width: 480, height: 300 },
},
}
Expand Down
1 change: 1 addition & 0 deletions src/main/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/main/lib/dialog/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/main/lib/project/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CONSTANTS from "@main/lib/constants"
import {
addLocalProject,
clearRecentProjects,
getPreferences,
getRecentProjects,
openProject,
Expand All @@ -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<AppPreferences>) =>
setPreferences(preferences),
Expand Down
13 changes: 13 additions & 0 deletions src/main/lib/project/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ export async function removeRecentProject(dir: string): Promise<Project[]> {
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<Project[]> {
const updated = await updateConfig(config => {
config.recentProjects = []
return undefined
})

return updated.recentProjects
}

/**
* Returns the current application preferences.
* @returns The preferences.
Expand Down
1 change: 1 addition & 0 deletions src/main/types/store.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type Project = {
*/
export type AppPreferences = {
startupBehavior: StartupBehavior
filesPaneWidth: number
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type GitgameApi = {
open: (dir: string) => Promise<OpenProjectResult>
getRecent: () => Promise<Project[]>
removeRecent: (dir: string) => Promise<Project[]>
clearRecent: () => Promise<Project[]>
getPreferences: () => Promise<AppPreferences>
setPreferences: (preferences: Partial<AppPreferences>) => Promise<AppPreferences>
}
Expand Down
1 change: 1 addition & 0 deletions src/preload/routes/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down
53 changes: 20 additions & 33 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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).
Expand All @@ -42,14 +40,26 @@ 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()
break
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
Expand All @@ -61,7 +71,7 @@ function AppShell() {
break
}
},
[addLocalProject, openProject],
[addLocalProject, openProject, clearRecentProjects],
)

// Bind the menu accelerators (Ctrl+O, Ctrl+Q, ...) to their actions
Expand All @@ -78,21 +88,7 @@ function AppShell() {

<MenuBar menus={menus} onAction={handleMenuAction} />

<div className="relative w-full flex-1 overflow-hidden">
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<img
src="./assets/images/cthulhu.png"
alt=""
className="w-[8%] [image-rendering:pixelated] opacity-15"
/>
</div>

{currentProject && (
<div className="relative h-full w-full">
<TreeView />
</div>
)}
</div>
<div className="relative w-full flex-1 overflow-hidden">{currentProject && <Workspace />}</div>

<StatusBar>
<StatusBarField grow>{currentProject ? currentProject.path : "No project open"}</StatusBarField>
Expand All @@ -102,25 +98,16 @@ function AppShell() {
)
}

/**
* Applies react95's Win95 scrollbar styling globally.
*/
const GlobalScrollbars = createGlobalStyle`
${createScrollbars()}
`

export default function App() {
return (
<ThemeProvider theme={originalTheme}>
<GlobalScrollbars />

<AppRoot>
<StatusProvider>
<ProjectProvider>
<TreeProvider>
<AppShell />
</TreeProvider>
</ProjectProvider>
</StatusProvider>
</ThemeProvider>
</AppRoot>
)
}
68 changes: 34 additions & 34 deletions src/renderer/DialogApp.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,70 @@
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<DialogOptions | null>(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 (
<ThemeProvider theme={originalTheme}>
<Window className="flex! h-screen! w-screen! flex-col">
<WindowHeader className="flex! items-center! justify-between! [-webkit-app-region:drag]">
<span className="truncate">{options?.title}</span>

<Button
size="sm"
square
className="[-webkit-app-region:no-drag]"
onClick={() => window.api.dialog.respond(false)}
>
<span className="font-bold">×</span>
</Button>
</WindowHeader>
<AppRoot>
<MainLayout>
<TitleBar title={options?.title ?? ""} mode="dialog" onClose={handleCancel} />

<WindowContent className="flex! min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col p-2">
<div className="min-h-0 flex-1 overflow-auto">
<p className="text-sm whitespace-pre-wrap">{options?.message}</p>

{options?.detail && <p className="mt-2 text-xs opacity-80">{options.detail}</p>}
</div>

<div className="mt-4 flex shrink-0 justify-end gap-2">
{isConfirm && (
<Button onClick={() => window.api.dialog.respond(false)}>
{options?.cancelLabel ?? "Cancel"}
</Button>
{options?.variant === "confirm" && (
<Button onClick={handleCancel}>{options?.cancelLabel ?? "Cancel"}</Button>
)}

<Button primary onClick={() => window.api.dialog.respond(true)}>
{isConfirm ? (options?.confirmLabel ?? "OK") : "OK"}
<Button primary onClick={handleConfirm}>
{options?.variant === "confirm" ? (options?.confirmLabel ?? "OK") : "OK"}
</Button>
</div>
</WindowContent>
</Window>
</ThemeProvider>
</div>
</MainLayout>
</AppRoot>
)
}
9 changes: 9 additions & 0 deletions src/renderer/components/contexts/Project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type ProjectContextType = {
addLocalProject: () => Promise<void>
openProject: (dir: string) => Promise<void>
removeRecentProject: (dir: string) => Promise<void>
clearRecentProjects: () => Promise<void>
closeProject: () => void
}

Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -135,6 +143,7 @@ export default function ProjectProvider({ children }: ProjectProviderProps) {
addLocalProject,
openProject,
removeRecentProject,
clearRecentProjects,
closeProject,
}}
>
Expand Down
25 changes: 25 additions & 0 deletions src/renderer/components/layouts/AppRoot.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ThemeProvider theme={originalTheme}>
<GlobalScrollbars />

{children}
</ThemeProvider>
)
}
Loading
Loading