├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yaml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── README.md ├── build ├── entitlements.mac.plist ├── icon.icns └── icon.png ├── components.json ├── dev-app-update.yml ├── electron-builder.yml ├── electron.vite.config.ts ├── package-lock.json ├── package.json ├── postcss.config.js ├── resources └── icon.png ├── src ├── app │ └── dashboard │ │ └── page.tsx ├── lib │ └── utils.ts ├── main │ └── index.ts ├── preload │ ├── index.d.ts │ ├── index.ts │ └── webview-preload.ts ├── renderer │ ├── index.html │ └── src │ │ ├── App.tsx │ │ ├── assets │ │ ├── base.css │ │ ├── electron.svg │ │ ├── index.css │ │ ├── main.css │ │ └── wavy-lines.svg │ │ ├── components │ │ ├── ForgePicker.tsx │ │ ├── MainApp.tsx │ │ ├── ThemeToggle.tsx │ │ ├── app-sidebar.tsx │ │ ├── composites │ │ │ ├── Panel.tsx │ │ │ ├── browser-navigation.tsx │ │ │ ├── file-table.tsx │ │ │ ├── panel-drag-overlay.tsx │ │ │ ├── panel-drop-zones.tsx │ │ │ ├── panel-title-bar.tsx │ │ │ ├── panel-title-bar │ │ │ │ ├── action-buttons.tsx │ │ │ │ ├── nav-buttons.tsx │ │ │ │ └── title.tsx │ │ │ └── path-breadcrumbs.tsx │ │ ├── navigation-listener.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── input.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── sidebar.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ └── tooltip.tsx │ │ └── views │ │ │ ├── Browser.tsx │ │ │ ├── FileView.tsx │ │ │ ├── Home.tsx │ │ │ ├── Node.tsx │ │ │ └── Workspace.tsx │ │ ├── contexts │ │ ├── FileCacheContext.tsx │ │ ├── PathContext.tsx │ │ ├── ThemeProvider.tsx │ │ ├── ViewContext.tsx │ │ └── WorkspaceContext.tsx │ │ ├── env.d.ts │ │ ├── hooks │ │ ├── use-mobile.tsx │ │ └── useFileSystem.ts │ │ ├── lib │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── stock │ │ └── Views.ts │ │ ├── stores │ │ ├── fileCache.ts │ │ └── workspace.ts │ │ └── types │ │ ├── browser.ts │ │ └── electron.d.ts └── types │ └── files.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.web.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .gitignore 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:react/recommended', 5 | 'plugin:react/jsx-runtime', 6 | '@electron-toolkit/eslint-config-ts/recommended', 7 | '@electron-toolkit/eslint-config-prettier' 8 | ], 9 | 10 | rules: { 11 | '@typescript-eslint/explicit-function-return-type': 'off' 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | jobs: 8 | release: 9 | runs-on: ${{ matrix.os }} 10 | permissions: 11 | contents: write 12 | issues: write 13 | pull-requests: write 14 | 15 | strategy: 16 | matrix: 17 | os: [macos-latest, ubuntu-latest, windows-latest] 18 | 19 | steps: 20 | - name: Check out Git repository 21 | uses: actions/checkout@v1 22 | 23 | - name: Install system dependencies 24 | if: runner.os == 'Linux' 25 | run: | 26 | sudo apt-get update 27 | sudo apt-get install -y libarchive-tools 28 | sudo snap install snapcraft --classic 29 | 30 | - name: Install Node.js, NPM and Yarn 31 | uses: actions/setup-node@v1 32 | with: 33 | node-version: 22 34 | 35 | - name: Build/release Electron app 36 | uses: samuelmeuli/action-electron-builder@v1 37 | with: 38 | # GitHub token, automatically provided to the action 39 | # (No need to define this secret in the repo settings) 40 | github_token: ${{ secrets.github_token }} 41 | 42 | # If the commit is tagged with a version (e.g. "v1.0.0"), 43 | # release the app after building 44 | release: ${{ startsWith(github.ref, 'refs/tags/v') }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .DS_Store 5 | *.log* 6 | release 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | pnpm-lock.yaml 4 | LICENSE.md 5 | tsconfig.json 6 | tsconfig.*.json 7 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 100 4 | trailingComma: none 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/src/renderer", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Brainforge 2 | 3 | https://github.com/user-attachments/assets/c6a8ef0c-2a50-4688-aa2b-ebfa523acc86 4 | 5 | Brainforge is a prototypical Obsidian clone. 6 | 7 | It offers: 8 | 9 | - _basic_ **Markdown** support and File Browsing 10 | - an integrated **browser** 11 | - **workspace** management 12 | 13 | ## Installation 14 | 15 | - Download Brainforge from the [releases](https://github.com/AlexW00/brainforge-desktop/releases) 16 | - for macos: also run `xattr -c ` (check [here](https://discussions.apple.com/thread/253714860?sortBy=best) for why this is necessary) after downloading 17 | 18 | ## Usage 19 | 20 | - pretty much documented in the video 21 | - to toggle md preview: cmd + e 22 | 23 | ## Notes 24 | 25 | 1. This is a protoype I built in ~1 week. Feel free to do whatever you want with it! 26 | 2. The idea was to build an integrated learning environment that supports flashcards, [Yomitan](https://github.com/yomidevs/yomitan)-like popovers and AI chat (I only got so far...) 27 | 3. I wanted to get this at least out, maybe someone has some use for it 28 | 4. It's only tested on Linux. 29 | 5. I won't work on this project anymore (maybe I'll start in a year or so again); Until then, I'm busy [learning Japanese (- as a software engineer)](https://alexanderweichart.de/4_Projects/how-i-learn-jp/How-I-learn-Japanese-(as-a-Software-Engineer))! 30 | 31 | PS: Don't open big ass folders with this :) 32 | -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexW00/brainforge-desktop/723206834d1adc95f8bc06e62c50d06d552c773d/build/icon.icns -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexW00/brainforge-desktop/723206834d1adc95f8bc06e62c50d06d552c773d/build/icon.png -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/renderer/src/assets/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "src/renderer/src/components", 14 | "utils": "src/renderer/src/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /dev-app-update.yml: -------------------------------------------------------------------------------- 1 | provider: generic 2 | url: https://example.com/auto-updates 3 | updaterCacheDirName: electron-app-updater 4 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: com.electron.app 2 | productName: electron-app 3 | directories: 4 | buildResources: build 5 | files: 6 | - '!**/.vscode/*' 7 | - '!src/*' 8 | - '!electron.vite.config.{js,ts,mjs,cjs}' 9 | - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' 10 | - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' 11 | - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' 12 | asarUnpack: 13 | - resources/** 14 | win: 15 | executableName: electron-app 16 | nsis: 17 | artifactName: ${name}-${version}-setup.${ext} 18 | shortcutName: ${productName} 19 | uninstallDisplayName: ${productName} 20 | createDesktopShortcut: always 21 | mac: 22 | entitlementsInherit: build/entitlements.mac.plist 23 | extendInfo: 24 | - NSCameraUsageDescription: Application requests access to the device's camera. 25 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone. 26 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 27 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 28 | notarize: false 29 | dmg: 30 | artifactName: ${name}-${version}.${ext} 31 | linux: 32 | target: 33 | - AppImage 34 | - snap 35 | - deb 36 | maintainer: electronjs.org 37 | category: Utility 38 | appImage: 39 | artifactName: ${name}-${version}.${ext} 40 | npmRebuild: false 41 | publish: 42 | provider: generic 43 | url: https://example.com/auto-updates 44 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 3 | import { resolve } from 'path' 4 | 5 | export default defineConfig({ 6 | main: { 7 | plugins: [externalizeDepsPlugin()] 8 | }, 9 | preload: { 10 | plugins: [externalizeDepsPlugin()], 11 | build: { 12 | rollupOptions: { 13 | input: { 14 | index: resolve(__dirname, 'src/preload/index.ts'), 15 | webviewPreload: resolve(__dirname, 'src/preload/webview-preload.ts') 16 | } 17 | } 18 | } 19 | }, 20 | renderer: { 21 | resolve: { 22 | alias: { 23 | '@renderer': resolve('src/renderer/src'), 24 | '@': resolve('src/renderer/src') 25 | } 26 | }, 27 | plugins: [react()] 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brainforge", 3 | "version": "1.1.0", 4 | "description": "An IDE for your brain", 5 | "main": "./out/main/index.js", 6 | "author": "Alexander Weichart", 7 | "homepage": "https://alexanderweichart.de", 8 | "license": "MIT", 9 | "build": { 10 | "appId": "com.aweichart.brainforge", 11 | "productName": "BrainForge" 12 | }, 13 | "scripts": { 14 | "format": "prettier --write .", 15 | "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", 16 | "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", 17 | "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", 18 | "typecheck": "npm run typecheck:node && npm run typecheck:web", 19 | "start": "electron-vite preview", 20 | "dev": "electron-vite dev", 21 | "build": "npm run typecheck && electron-vite build", 22 | "postinstall": "electron-builder install-app-deps", 23 | "build:unpack": "npm run build && electron-builder --dir", 24 | "build:win": "npm run build && electron-builder --win", 25 | "build:mac": "electron-vite build && electron-builder --mac", 26 | "build:linux": "electron-vite build && electron-builder --linux", 27 | "app:dir": "electron-builder --dir", 28 | "app:dist": "electron-builder" 29 | }, 30 | "dependencies": { 31 | "@codemirror/lang-markdown": "^6.3.2", 32 | "@devbookhq/splitter": "^1.4.2", 33 | "@dnd-kit/core": "^6.3.1", 34 | "@dnd-kit/sortable": "^10.0.0", 35 | "@dnd-kit/utilities": "^3.2.2", 36 | "@electron-toolkit/preload": "^3.0.1", 37 | "@electron-toolkit/utils": "^3.0.0", 38 | "@fsegurai/codemirror-theme-bundle": "^6.1.2", 39 | "@radix-ui/react-accordion": "^1.2.2", 40 | "@radix-ui/react-avatar": "^1.1.2", 41 | "@radix-ui/react-collapsible": "^1.1.2", 42 | "@radix-ui/react-context-menu": "^2.2.5", 43 | "@radix-ui/react-dialog": "^1.1.4", 44 | "@radix-ui/react-dropdown-menu": "^2.1.4", 45 | "@radix-ui/react-icons": "^1.3.2", 46 | "@radix-ui/react-scroll-area": "^1.2.2", 47 | "@radix-ui/react-separator": "^1.1.1", 48 | "@radix-ui/react-slot": "^1.1.1", 49 | "@radix-ui/react-switch": "^1.1.2", 50 | "@radix-ui/react-tabs": "^1.1.2", 51 | "@radix-ui/react-tooltip": "^1.1.6", 52 | "@types/mime-types": "^2.1.4", 53 | "@types/react-router-dom": "^5.3.3", 54 | "@uiw/react-codemirror": "^4.23.7", 55 | "@uiw/react-md-editor": "^4.0.5", 56 | "chokidar": "^4.0.3", 57 | "class-variance-authority": "^0.7.1", 58 | "clsx": "^2.1.1", 59 | "codemirror": "^6.0.1", 60 | "defer-to-connect": "^2.0.1", 61 | "electron-updater": "^6.1.7", 62 | "framer-motion": "^11.15.0", 63 | "immer": "^10.1.1", 64 | "lucide-react": "^0.469.0", 65 | "mime-types": "^2.1.35", 66 | "next-themes": "^0.4.4", 67 | "react-markdown": "^9.0.3", 68 | "react-router-dom": "^7.1.3", 69 | "rehype-raw": "^7.0.0", 70 | "tailwind-merge": "^2.6.0", 71 | "tailwindcss-animate": "^1.0.7", 72 | "uuid": "^11.0.5", 73 | "zustand": "^5.0.3" 74 | }, 75 | "devDependencies": { 76 | "@electron-toolkit/eslint-config-prettier": "^2.0.0", 77 | "@electron-toolkit/eslint-config-ts": "^2.0.0", 78 | "@electron-toolkit/tsconfig": "^1.0.1", 79 | "@tailwindcss/typography": "^0.5.16", 80 | "@types/chokidar": "^2.1.7", 81 | "@types/node": "^20.14.8", 82 | "@types/react": "^18.3.3", 83 | "@types/react-dom": "^18.3.0", 84 | "@types/uuid": "^10.0.0", 85 | "@vitejs/plugin-react": "^4.3.1", 86 | "autoprefixer": "^10.4.20", 87 | "electron": "^31.0.2", 88 | "electron-builder": "^25.1.8", 89 | "electron-vite": "^2.3.0", 90 | "eslint": "^8.57.0", 91 | "eslint-plugin-react": "^7.34.3", 92 | "postcss": "^8.4.49", 93 | "prettier": "^3.3.2", 94 | "react": "^18.3.1", 95 | "react-dom": "^18.3.1", 96 | "tailwindcss": "^3.4.17", 97 | "typescript": "^5.5.2", 98 | "vite": "^5.3.1" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexW00/brainforge-desktop/723206834d1adc95f8bc06e62c50d06d552c773d/resources/icon.png -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { AppSidebar } from './app-sidebar' 2 | import { 3 | Breadcrumb, 4 | BreadcrumbItem, 5 | BreadcrumbLink, 6 | BreadcrumbList, 7 | BreadcrumbPage, 8 | BreadcrumbSeparator 9 | } from './ui/breadcrumb' 10 | import { Separator } from './ui/separator' 11 | import { SidebarInset, SidebarProvider, SidebarTrigger } from './ui/sidebar' 12 | 13 | export default function Page() { 14 | return ( 15 | 16 | 17 | 18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | Building Your Application 26 | 27 | 28 | 29 | Data Fetching 30 | 31 | 32 | 33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { electronApp, is, optimizer } from '@electron-toolkit/utils' 2 | import type { FSWatcher } from 'chokidar' 3 | import chokidar from 'chokidar' 4 | import { app, BrowserWindow, dialog, ipcMain } from 'electron' 5 | import { lookup } from 'mime-types' 6 | import { mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises' 7 | import { homedir } from 'node:os' 8 | import { join } from 'node:path' 9 | import { v4 as uuidv4 } from 'uuid' 10 | 11 | interface FileWatcher { 12 | watcher: FSWatcher 13 | window: BrowserWindow 14 | } 15 | 16 | const watchers = new Map() 17 | 18 | let forgePickerWindow: BrowserWindow | null = null 19 | let mainWindow: BrowserWindow | null = null 20 | let selectedForgePath: string | null = null 21 | 22 | const STATE_DIR = join(homedir(), '.brainforge') 23 | const STATE_FILE = join(STATE_DIR, 'state.json') 24 | 25 | interface AppState { 26 | recentForges: string[] 27 | lastActiveForge: string | null 28 | } 29 | 30 | async function ensureStateDir(): Promise { 31 | try { 32 | await mkdir(STATE_DIR, { recursive: true }) 33 | } catch (error) { 34 | console.error('Failed to create state directory:', error) 35 | } 36 | } 37 | 38 | async function loadState(): Promise { 39 | try { 40 | const data = await readFile(STATE_FILE, 'utf-8') 41 | return JSON.parse(data) 42 | } catch { 43 | return { recentForges: [], lastActiveForge: null } 44 | } 45 | } 46 | 47 | async function saveState(state: AppState): Promise { 48 | try { 49 | await writeFile(STATE_FILE, JSON.stringify(state, null, 2)) 50 | } catch (error) { 51 | console.error('Failed to save state:', error) 52 | } 53 | } 54 | 55 | function createForgePickerWindow(): void { 56 | forgePickerWindow = new BrowserWindow({ 57 | width: 480, 58 | height: 400, 59 | show: false, 60 | autoHideMenuBar: true, 61 | resizable: false, 62 | maximizable: false, 63 | fullscreenable: false, 64 | backgroundColor: '#ffffff', 65 | ...(process.platform === 'linux' ? { icon: join(__dirname, '../../build/icon.png') } : {}), 66 | webPreferences: { 67 | preload: join(__dirname, '../preload/index.js'), 68 | sandbox: false, 69 | contextIsolation: true 70 | } 71 | }) 72 | 73 | forgePickerWindow.on('ready-to-show', () => { 74 | forgePickerWindow?.show() 75 | }) 76 | 77 | if (is.dev && process.env['ELECTRON_RENDERER_URL']) { 78 | forgePickerWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/#/forge-picker`) 79 | } else { 80 | forgePickerWindow.loadFile(join(__dirname, '../renderer/index.html'), { 81 | hash: 'forge-picker' 82 | }) 83 | } 84 | } 85 | 86 | function createWindow(): void { 87 | // Create the browser window. 88 | mainWindow = new BrowserWindow({ 89 | show: true, 90 | autoHideMenuBar: true, 91 | ...(process.platform === 'linux' ? { icon: join(__dirname, '../../build/icon.png') } : {}), 92 | webPreferences: { 93 | preload: join(__dirname, '../preload/index.js'), 94 | sandbox: false, 95 | webviewTag: true, 96 | contextIsolation: true, 97 | webSecurity: false, 98 | zoomFactor: 1.0 99 | } 100 | }) 101 | 102 | // Register zoom keyboard shortcuts 103 | mainWindow.webContents.on('before-input-event', (event, input) => { 104 | if (input.control) { 105 | if (input.key === '=' || input.key === '+') { 106 | event.preventDefault() 107 | const currentZoom = mainWindow?.webContents.getZoomFactor() || 1.0 108 | mainWindow?.webContents.setZoomFactor(Math.min(currentZoom + 0.1, 3.0)) 109 | } else if (input.key === '-') { 110 | event.preventDefault() 111 | const currentZoom = mainWindow?.webContents.getZoomFactor() || 1.0 112 | mainWindow?.webContents.setZoomFactor(Math.max(currentZoom - 0.1, 0.3)) 113 | } else if (input.key === '0') { 114 | event.preventDefault() 115 | mainWindow?.webContents.setZoomFactor(1.0) 116 | } 117 | } 118 | }) 119 | 120 | mainWindow.on('ready-to-show', () => { 121 | mainWindow?.setTitle('BrainForge') 122 | // mainWindow?.maximize() 123 | mainWindow?.show() 124 | }) 125 | 126 | // HMR for renderer base on electron-vite cli. 127 | // Load the remote URL for development or the local html file for production. 128 | if (is.dev && process.env['ELECTRON_RENDERER_URL']) { 129 | mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) 130 | } else { 131 | mainWindow.loadFile(join(__dirname, '../renderer/index.html')) 132 | } 133 | 134 | mainWindow.webContents.on('will-attach-webview', (_e, webPreferences) => { 135 | webPreferences.preload = join(__dirname, '../preload/webviewPreload.js') 136 | }) 137 | } 138 | 139 | // This method will be called when Electron has finished 140 | // initialization and is ready to create browser windows. 141 | // Some APIs can only be used after this event occurs. 142 | app.whenReady().then(async () => { 143 | await ensureStateDir() 144 | 145 | // Set app user model id for windows 146 | electronApp.setAppUserModelId('com.electron') 147 | 148 | // Register all IPC handlers first 149 | ipcMain.handle('getHomePath', () => { 150 | return selectedForgePath || homedir() 151 | }) 152 | 153 | ipcMain.handle('readFile', async (_, path: string) => { 154 | try { 155 | const stats = await stat(path) 156 | if (!stats.isFile()) { 157 | throw new Error('Not a file') 158 | } 159 | const content = await readFile(path, 'utf-8') 160 | return content 161 | } catch (error) { 162 | console.error('Error reading file:', error) 163 | throw error 164 | } 165 | }) 166 | 167 | ipcMain.handle('writeFile', async (_, path: string, content: string) => { 168 | try { 169 | await writeFile(path, content, 'utf-8') 170 | } catch (error) { 171 | console.error('Error writing file:', error) 172 | throw error 173 | } 174 | }) 175 | 176 | ipcMain.handle('getRecentForges', async () => { 177 | const state = await loadState() 178 | return state.recentForges 179 | }) 180 | 181 | ipcMain.handle('selectForge', async (_, path: string) => { 182 | console.log('Selecting forge path:', path) 183 | selectedForgePath = path 184 | const state = await loadState() 185 | const recentForges = [path, ...state.recentForges.filter((p) => p !== path)].slice(0, 10) 186 | await saveState({ ...state, recentForges, lastActiveForge: path }) 187 | 188 | try { 189 | await stat(path) 190 | } catch (error) { 191 | console.error('Error accessing selected forge path:', error) 192 | throw new Error(`Cannot access selected directory: ${path}`) 193 | } 194 | 195 | // Close all existing windows 196 | BrowserWindow.getAllWindows().forEach((window) => window.close()) 197 | 198 | // Create new main window 199 | createWindow() 200 | }) 201 | 202 | ipcMain.handle('readDir', async (_, path: string) => { 203 | try { 204 | const entries = await readdir(path, { withFileTypes: true }) 205 | return entries 206 | .filter((entry) => !entry.isSymbolicLink()) 207 | .map((entry) => ({ 208 | name: entry.name, 209 | type: entry.isDirectory() ? 'folder' : 'file', 210 | path: join(path, entry.name), 211 | mimeType: entry.isDirectory() 212 | ? 'folder' 213 | : lookup(entry.name) || 'application/octet-stream' 214 | })) 215 | } catch (error) { 216 | console.error('Error reading directory:', error) 217 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 218 | throw new Error(`Directory not found: ${path}`) 219 | } 220 | throw error 221 | } 222 | }) 223 | 224 | ipcMain.handle('joinPath', (_, ...paths: string[]) => { 225 | return join(...paths) 226 | }) 227 | 228 | ipcMain.handle('watchFiles', async (event, path: string) => { 229 | const watcherId = uuidv4() 230 | const window = BrowserWindow.fromWebContents(event.sender)! 231 | 232 | const watcher = chokidar.watch(path, { 233 | persistent: true, 234 | ignoreInitial: true 235 | }) 236 | 237 | watcher 238 | .on('add', (path) => { 239 | window.webContents.send('fileEvent', { type: 'add', watcherId, path }) 240 | }) 241 | .on('addDir', (path) => { 242 | window.webContents.send('fileEvent', { type: 'add', watcherId, path }) 243 | }) 244 | .on('change', (path) => { 245 | window.webContents.send('fileEvent', { type: 'change', watcherId, path }) 246 | }) 247 | .on('unlink', (path) => { 248 | window.webContents.send('fileEvent', { type: 'unlink', watcherId, path }) 249 | }) 250 | .on('unlinkDir', (path) => { 251 | window.webContents.send('fileEvent', { type: 'unlink', watcherId, path }) 252 | }) 253 | 254 | watchers.set(watcherId, { watcher, window }) 255 | return watcherId 256 | }) 257 | 258 | ipcMain.handle('unwatchFiles', async (_, watcherId: string) => { 259 | const watcher = watchers.get(watcherId) 260 | if (watcher) { 261 | await watcher.watcher.close() 262 | watchers.delete(watcherId) 263 | } 264 | }) 265 | 266 | ipcMain.handle('getStats', async (_, path: string) => { 267 | const stats = await stat(path) 268 | return { 269 | isDirectory: stats.isDirectory(), 270 | isFile: stats.isFile(), 271 | mtime: stats.mtime.getTime(), 272 | mimeType: stats.isDirectory() ? 'folder' : lookup(path) || 'application/octet-stream' 273 | } 274 | }) 275 | 276 | ipcMain.handle('mkdir', async (_, path: string) => { 277 | try { 278 | await mkdir(path, { recursive: true }) 279 | } catch (error) { 280 | console.error('Error creating directory:', error) 281 | throw error 282 | } 283 | }) 284 | 285 | ipcMain.handle('rename', async (_, oldPath: string, newPath: string) => { 286 | try { 287 | await rename(oldPath, newPath) 288 | } catch (error) { 289 | console.error('Error renaming file:', error) 290 | throw error 291 | } 292 | }) 293 | 294 | ipcMain.handle('deleteFile', async (_, path: string) => { 295 | try { 296 | const stats = await stat(path) 297 | if (stats.isDirectory()) { 298 | await rm(path, { recursive: true }) 299 | } else { 300 | await unlink(path) 301 | } 302 | } catch (error) { 303 | console.error('Error deleting file:', error) 304 | throw error 305 | } 306 | }) 307 | 308 | ipcMain.handle('dialog:openDirectory', async () => { 309 | const { canceled, filePaths } = await dialog.showOpenDialog({ 310 | properties: ['openDirectory'] 311 | }) 312 | if (!canceled && filePaths.length > 0) { 313 | return filePaths[0] 314 | } 315 | return null 316 | }) 317 | 318 | ipcMain.handle('openForgePicker', () => { 319 | createForgePickerWindow() 320 | }) 321 | 322 | // Default open or close DevTools by F12 in development 323 | app.on('browser-window-created', (_, window) => { 324 | optimizer.watchWindowShortcuts(window) 325 | }) 326 | 327 | // Now check for last active forge and create appropriate window 328 | const state = await loadState() 329 | if (state.lastActiveForge) { 330 | try { 331 | await stat(state.lastActiveForge) 332 | selectedForgePath = state.lastActiveForge 333 | createWindow() 334 | } catch { 335 | console.log('Last active forge no longer accessible') 336 | createForgePickerWindow() 337 | } 338 | } else { 339 | createForgePickerWindow() 340 | } 341 | }) 342 | 343 | // Quit when all windows are closed, except on macOS. There, it's common 344 | // for applications and their menu bar to stay active until the user quits 345 | // explicitly with Cmd + Q. 346 | app.on('window-all-closed', () => { 347 | if (process.platform !== 'darwin') { 348 | app.quit() 349 | } 350 | }) 351 | 352 | // Clean up file watchers when quitting 353 | app.on('before-quit', async () => { 354 | for (const { watcher } of watchers.values()) { 355 | await watcher.close() 356 | } 357 | watchers.clear() 358 | }) 359 | -------------------------------------------------------------------------------- /src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron' 2 | import type { FileWatcher, FileWatcherOptions } from '../types/files' 3 | 4 | // Custom APIs for renderer 5 | const api = { 6 | getHomePath: () => ipcRenderer.invoke('getHomePath'), 7 | readDir: (path: string) => ipcRenderer.invoke('readDir', path), 8 | joinPath: (...paths: string[]) => ipcRenderer.invoke('joinPath', ...paths), 9 | getStats: (path: string) => ipcRenderer.invoke('getStats', path), 10 | getRecentForges: () => ipcRenderer.invoke('getRecentForges'), 11 | selectForge: (path: string) => ipcRenderer.invoke('selectForge', path), 12 | openForgePicker: () => ipcRenderer.invoke('openForgePicker'), 13 | openDirectory: () => ipcRenderer.invoke('dialog:openDirectory'), 14 | readFile: (path: string) => ipcRenderer.invoke('readFile', path), 15 | writeFile: (path: string, content: string) => ipcRenderer.invoke('writeFile', path, content), 16 | mkdir: (path: string) => ipcRenderer.invoke('mkdir', path), 17 | rename: (oldPath: string, newPath: string) => ipcRenderer.invoke('rename', oldPath, newPath), 18 | deleteFile: (path: string) => ipcRenderer.invoke('deleteFile', path), 19 | watchFiles: async (path: string, options: FileWatcherOptions): Promise => { 20 | const watcherId = await ipcRenderer.invoke('watchFiles', path) 21 | 22 | // Set up event listeners for this watcher 23 | const eventHandler = ( 24 | _event: IpcRendererEvent, 25 | data: { type: 'add' | 'change' | 'unlink'; watcherId: string; path: string } 26 | ) => { 27 | if (data.watcherId !== watcherId) return 28 | 29 | if (data.type === 'add') options.onAdd(data.path) 30 | else if (data.type === 'change') options.onChange(data.path) 31 | else if (data.type === 'unlink') options.onUnlink(data.path) 32 | } 33 | 34 | ipcRenderer.on('fileEvent', eventHandler) 35 | 36 | return { 37 | id: watcherId, 38 | stop: async () => { 39 | ipcRenderer.removeListener('fileEvent', eventHandler) 40 | await ipcRenderer.invoke('unwatchFiles', watcherId) 41 | } 42 | } 43 | } 44 | } 45 | 46 | // Use `contextBridge` APIs to expose Electron APIs to 47 | // renderer only if context isolation is enabled, otherwise 48 | // just add to the DOM global. 49 | if (process.contextIsolated) { 50 | try { 51 | contextBridge.exposeInMainWorld('api', api) 52 | } catch (error) { 53 | console.error(error) 54 | } 55 | } else { 56 | // @ts-ignore (define in dts) 57 | window.api = api 58 | } 59 | -------------------------------------------------------------------------------- /src/preload/webview-preload.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | 3 | const onScanElement = (element: HTMLElement, x: number) => { 4 | const outerHtml = element.outerHTML 5 | const textContent = element.textContent 6 | 7 | // Create a range from the mouse position 8 | const range = document.createRange() 9 | const textNode = element.firstChild 10 | if (textNode && textNode.nodeType === Node.TEXT_NODE) { 11 | // Get approximate character position based on x coordinate 12 | const rect = element.getBoundingClientRect() 13 | const relativeX = x - rect.left 14 | const approximateOffset = Math.floor((relativeX / rect.width) * textContent!.length) 15 | 16 | range.setStart(textNode, Math.min(approximateOffset, textContent!.length)) 17 | const preciseText = textNode.textContent || '' 18 | 19 | ipcRenderer.sendToHost('scan-element', { 20 | outerHtml, 21 | textContent, 22 | preciseText, 23 | offset: approximateOffset 24 | }) 25 | } else { 26 | ipcRenderer.sendToHost('scan-element', { 27 | outerHtml, 28 | textContent, 29 | preciseText: '', 30 | offset: 0 31 | }) 32 | } 33 | } 34 | 35 | window.addEventListener('keydown', (event) => { 36 | if (event.altKey) { 37 | if (event.key === 'ArrowLeft') { 38 | ipcRenderer.sendToHost('navigation-event', { type: 'back' }) 39 | } else if (event.key === 'ArrowRight') { 40 | ipcRenderer.sendToHost('navigation-event', { type: 'forward' }) 41 | } 42 | } 43 | }) 44 | 45 | window.addEventListener('mouseup', (event) => { 46 | if (event.button === 3 || event.button === 4) { 47 | ipcRenderer.sendToHost('navigation-event', { 48 | type: event.button === 3 ? 'back' : 'forward' 49 | }) 50 | } else if (event.shiftKey) { 51 | const element = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement 52 | if (element) { 53 | onScanElement(element, event.clientX) 54 | } 55 | } 56 | }) 57 | 58 | window.addEventListener('mousemove', (event) => { 59 | if (event.shiftKey) { 60 | const element = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement 61 | if (element) { 62 | onScanElement(element, event.clientX) 63 | } 64 | } 65 | }) 66 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Electron 6 | 7 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/renderer/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, HashRouter as Router, Routes } from 'react-router-dom' 2 | import { ForgePicker } from './components/ForgePicker' 3 | import { MainApp } from './components/MainApp' 4 | 5 | export function App(): JSX.Element { 6 | return ( 7 | 8 | 9 | } /> 10 | } /> 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/src/assets/base.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --ev-c-white: #ffffff; 3 | --ev-c-white-soft: #f8f8f8; 4 | --ev-c-white-mute: #f2f2f2; 5 | 6 | --ev-c-black: #1b1b1f; 7 | --ev-c-black-soft: #222222; 8 | --ev-c-black-mute: #282828; 9 | 10 | --ev-c-gray-1: #515c67; 11 | --ev-c-gray-2: #414853; 12 | --ev-c-gray-3: #32363f; 13 | 14 | --ev-c-text-1: rgba(255, 255, 245, 0.86); 15 | --ev-c-text-2: rgba(235, 235, 245, 0.6); 16 | --ev-c-text-3: rgba(235, 235, 245, 0.38); 17 | 18 | --ev-button-alt-border: transparent; 19 | --ev-button-alt-text: var(--ev-c-text-1); 20 | --ev-button-alt-bg: var(--ev-c-gray-3); 21 | --ev-button-alt-hover-border: transparent; 22 | --ev-button-alt-hover-text: var(--ev-c-text-1); 23 | --ev-button-alt-hover-bg: var(--ev-c-gray-2); 24 | } 25 | 26 | :root { 27 | --color-background: hsl(var(--background)); 28 | --color-background-soft: hsl(var(--background)); 29 | --color-background-mute: hsl(var(--muted)); 30 | --color-text: hsl(var(--foreground)); 31 | } 32 | 33 | *, 34 | *::before, 35 | *::after { 36 | box-sizing: border-box; 37 | margin: 0; 38 | font-weight: normal; 39 | } 40 | 41 | ul { 42 | list-style: none; 43 | } 44 | 45 | body { 46 | min-height: 100vh; 47 | color: var(--color-text); 48 | background: hsl(var(--background)); 49 | line-height: 1.6; 50 | font-family: 51 | Inter, 52 | -apple-system, 53 | BlinkMacSystemFont, 54 | 'Segoe UI', 55 | Roboto, 56 | Oxygen, 57 | Ubuntu, 58 | Cantarell, 59 | 'Fira Sans', 60 | 'Droid Sans', 61 | 'Helvetica Neue', 62 | sans-serif; 63 | text-rendering: optimizeLegibility; 64 | -webkit-font-smoothing: antialiased; 65 | -moz-osx-font-smoothing: grayscale; 66 | } 67 | 68 | 69 | .custom-gutter-horizontal { 70 | height: 100%; 71 | width: 10px; 72 | } 73 | .custom-gutter-horizontal:hover { 74 | cursor: col-resize; 75 | } 76 | 77 | .custom-gutter-vertical { 78 | height: 10px; 79 | width: 100%; 80 | } 81 | .custom-gutter-vertical:hover { 82 | cursor: row-resize; 83 | } 84 | -------------------------------------------------------------------------------- /src/renderer/src/assets/electron.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/renderer/src/assets/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 0%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 0 0% 0%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 0 0% 0%; 13 | --primary: 0 0% 0%; 14 | --primary-foreground: 0 0% 100%; 15 | --secondary: 0 0% 96%; 16 | --secondary-foreground: 0 0% 0%; 17 | --muted: 0 0% 96%; 18 | --muted-foreground: 0 0% 45%; 19 | --accent: 0 0% 0%; 20 | --accent-foreground: 0 0% 100%; 21 | --destructive: 0 84% 60%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 0 0% 0% / 0.1; 24 | --input: 0 0% 90%; 25 | --ring: 0 0% 0%; 26 | --radius: 0.5rem; 27 | 28 | --sidebar: 0 0% 100%; 29 | --sidebar-foreground: 0 0% 0%; 30 | --sidebar-border: 0 0% 0% / 0.1; 31 | --sidebar-ring: 0 0% 0%; 32 | --sidebar-accent: 0 0% 96%; 33 | --sidebar-accent-foreground: 0 0% 0%; 34 | --sidebar-background: 0 0% 100%; 35 | } 36 | 37 | .dark { 38 | --background: 0 0% 0%; 39 | --foreground: 0 0% 100%; 40 | --card: 0 0% 0%; 41 | --card-foreground: 0 0% 100%; 42 | --popover: 0 0% 0%; 43 | --popover-foreground: 0 0% 100%; 44 | --primary: 0 0% 100%; 45 | --primary-foreground: 0 0% 0%; 46 | --secondary: 0 0% 15%; 47 | --secondary-foreground: 0 0% 100%; 48 | --muted: 0 0% 15%; 49 | --muted-foreground: 0 0% 65%; 50 | --accent: 0 0% 100%; 51 | --accent-foreground: 0 0% 0%; 52 | --destructive: 0 84% 60%; 53 | --destructive-foreground: 0 0% 98%; 54 | --border: 0 0% 100% / 0.1; 55 | --input: 0 0% 20%; 56 | --ring: 0 0% 100%; 57 | 58 | --sidebar: 0 0% 0%; 59 | --sidebar-foreground: 0 0% 100%; 60 | --sidebar-border: 0 0% 100% / 0.1; 61 | --sidebar-ring: 0 0% 100%; 62 | --sidebar-accent: 0 0% 15%; 63 | --sidebar-accent-foreground: 0 0% 100%; 64 | --sidebar-background: 0 0% 0%; 65 | } 66 | } 67 | 68 | @layer base { 69 | * { 70 | @apply border-border; 71 | } 72 | body { 73 | @apply bg-background text-foreground; 74 | } 75 | } 76 | 77 | /* Utility class to disable selection */ 78 | .no-select { 79 | -webkit-user-select: none; 80 | -moz-user-select: none; 81 | -ms-user-select: none; 82 | user-select: none; 83 | pointer-events: none; 84 | } 85 | -------------------------------------------------------------------------------- /src/renderer/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import './base.css'; 2 | -------------------------------------------------------------------------------- /src/renderer/src/assets/wavy-lines.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/renderer/src/components/ForgePicker.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button' 2 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 3 | import { ScrollArea } from '@/components/ui/scroll-area' 4 | import { FolderOpen, History } from 'lucide-react' 5 | import { useEffect, useState } from 'react' 6 | import { create } from 'zustand' 7 | 8 | interface ForgeStore { 9 | recentForges: string[] 10 | setRecentForges: (forges: string[]) => void 11 | } 12 | 13 | const useForgeStore = create((set) => ({ 14 | recentForges: [], 15 | setRecentForges: (forges) => set({ recentForges: forges }) 16 | })) 17 | 18 | export function ForgePicker(): JSX.Element { 19 | const { recentForges, setRecentForges } = useForgeStore() 20 | const [isLoading, setIsLoading] = useState(false) 21 | 22 | useEffect(() => { 23 | window.api.getRecentForges().then(setRecentForges) 24 | }, []) 25 | 26 | const handleSelectFolder = async () => { 27 | setIsLoading(true) 28 | try { 29 | const result = await window.api.openDirectory() 30 | if (result) { 31 | await window.api.selectForge(result) 32 | } 33 | } finally { 34 | setIsLoading(false) 35 | } 36 | } 37 | 38 | const handleForgeSelect = async (path: string) => { 39 | setIsLoading(true) 40 | try { 41 | await window.api.selectForge(path) 42 | } finally { 43 | setIsLoading(false) 44 | } 45 | } 46 | 47 | return ( 48 |
49 | 50 | 51 | Select Forge 52 | Choose a folder to open or select from recent forges 53 | 54 | 55 | 59 | 60 | {recentForges.length > 0 && ( 61 |
62 |
63 | 64 | Recent Forges 65 |
66 |
67 | 68 |
69 | {recentForges.map((forge) => ( 70 | 79 | ))} 80 |
81 |
82 |
83 |
84 | )} 85 |
86 |
87 |
88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/renderer/src/components/MainApp.tsx: -------------------------------------------------------------------------------- 1 | import { TooltipProvider } from '@/components/ui/tooltip' 2 | import { useEffect, useState } from 'react' 3 | import { FileCacheProvider } from '../contexts/FileCacheContext' 4 | import { WorkspaceProvider } from '../contexts/WorkspaceContext' 5 | import { AppSidebar } from './app-sidebar' 6 | import { WorkspaceView } from './views/Workspace' 7 | 8 | function MouseGlow() { 9 | const [position, setPosition] = useState({ x: 0, y: 0 }) 10 | 11 | useEffect(() => { 12 | const handleMouseMove = (e: MouseEvent) => { 13 | setPosition({ x: e.clientX, y: e.clientY }) 14 | } 15 | window.addEventListener('mousemove', handleMouseMove) 16 | return () => window.removeEventListener('mousemove', handleMouseMove) 17 | }, []) 18 | 19 | return ( 20 |
26 | ) 27 | } 28 | 29 | export function MainApp(): JSX.Element { 30 | return ( 31 | 32 | 33 | 34 |
35 | 36 |
37 | 38 |
39 | 40 |
41 |
42 |
43 |
44 |
45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer/src/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from 'next-themes' 2 | import { SunIcon, MoonIcon } from '@radix-ui/react-icons' 3 | import { Switch } from '@/components/ui/switch' 4 | 5 | export function ThemeToggle() { 6 | const { theme, setTheme } = useTheme() 7 | 8 | return ( 9 | setTheme(theme === 'dark' ? 'light' : 'dark')} 12 | className="data-[state=checked]:bg-slate-700" 13 | > 14 | 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/src/components/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeftRight, FolderOpen, Globe, LucideIcon } from 'lucide-react' 2 | 3 | import { useWorkspace } from '../contexts/WorkspaceContext' 4 | import { cn } from '../lib/utils' 5 | import { ViewName } from '../stock/Views' 6 | import { Button } from './ui/button' 7 | import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip' 8 | 9 | interface NavItem { 10 | title: string 11 | view: ViewName 12 | icon: LucideIcon 13 | } 14 | 15 | export function AppSidebar(): JSX.Element { 16 | const { navigate, activeViewId, insertRootView } = useWorkspace() 17 | const navItems: NavItem[] = [ 18 | { 19 | title: 'Files', 20 | view: 'files', 21 | icon: FolderOpen 22 | }, 23 | { 24 | title: 'Browser', 25 | view: 'browser', 26 | icon: Globe 27 | } 28 | ] 29 | 30 | const handleItemClick = (view: ViewName) => { 31 | if (activeViewId) { 32 | navigate(activeViewId, view) 33 | } else { 34 | insertRootView([{ name: view, props: {} }]) 35 | } 36 | } 37 | 38 | const handleForgePickerClick = () => { 39 | window.api.openForgePicker() 40 | } 41 | 42 | return ( 43 |
44 |
45 | {navItems.map((item) => ( 46 | 47 | 48 | 56 | 57 | 58 |

{item.title}

59 |
60 |
61 | ))} 62 |
63 | 64 |
65 | 66 | 67 | 75 | 76 | 77 |

Change Forge

78 |
79 |
80 |
81 |
82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /src/renderer/src/components/composites/Panel.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@/components/ui/card' 2 | import { SplitDirection } from '@devbookhq/splitter' 3 | import { LucideIcon } from 'lucide-react' 4 | import { useEffect, useRef, useState } from 'react' 5 | import { ViewName } from '../../stock/Views' 6 | import { PanelDropZones } from './panel-drop-zones' 7 | import { PanelTitleBar } from './panel-title-bar' 8 | 9 | interface PanelProps { 10 | viewId: string 11 | name: ViewName 12 | Icon: LucideIcon 13 | onActivate: (viewId: string) => void 14 | onClose: () => void 15 | onSplit: (direction: SplitDirection, insertAt?: 'before' | 'after') => void 16 | children: React.ReactNode 17 | isActive: boolean 18 | isDragging?: boolean 19 | activeDropId?: string 20 | draggedId?: string 21 | } 22 | 23 | export function Panel({ 24 | viewId, 25 | name, 26 | Icon, 27 | onActivate, 28 | onClose, 29 | children, 30 | onSplit, 31 | isActive, 32 | isDragging, 33 | activeDropId, 34 | draggedId 35 | }: PanelProps) { 36 | const panelRef = useRef(null) 37 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) 38 | 39 | useEffect(() => { 40 | const panel = panelRef.current 41 | if (!panel) return 42 | 43 | const resizeObserver = new ResizeObserver((entries) => { 44 | const entry = entries[0] 45 | if (entry) { 46 | console.log('Panel resize', entry.contentRect.width, entry.contentRect.height) 47 | setDimensions({ 48 | width: entry.contentRect.width, 49 | height: entry.contentRect.height 50 | }) 51 | } 52 | }) 53 | 54 | resizeObserver.observe(panel) 55 | return () => resizeObserver.disconnect() 56 | }, []) 57 | 58 | console.log('Panel render', viewId, draggedId) 59 | return ( 60 | onActivate(viewId)} 64 | style={{ 65 | borderColor: 'hsl(var(--border))', 66 | borderWidth: '1px', 67 | borderStyle: 'solid' 68 | }} 69 | > 70 | = 400} 78 | canSplitVertical={dimensions.width >= 400} 79 | /> 80 |
{children}
81 | 87 |
88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/renderer/src/components/composites/browser-navigation.tsx: -------------------------------------------------------------------------------- 1 | import { RefreshCw } from 'lucide-react' 2 | import { useEffect, useState } from 'react' 3 | import { BrowserNavigationProps } from '../../types/browser' 4 | import { Button } from '../ui/button' 5 | import { Input } from '../ui/input' 6 | 7 | export function BrowserNavigation({ 8 | url, 9 | onNavigate: onUrlChange, 10 | onRefresh 11 | }: BrowserNavigationProps) { 12 | const [inputValue, setInputValue] = useState(url || '') 13 | 14 | const handleSubmit = (e: React.FormEvent) => { 15 | e.preventDefault() 16 | onUrlChange(inputValue) 17 | } 18 | 19 | useEffect(() => { 20 | setInputValue(url) 21 | }, [url]) 22 | 23 | return ( 24 |
25 | 28 |
29 | setInputValue(e.target.value)} 34 | /> 35 |
36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/renderer/src/components/composites/file-table.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FileIcon, 3 | FilePlusIcon, 4 | FolderIcon, 5 | FolderPlusIcon, 6 | Pencil, 7 | SearchIcon, 8 | Trash2 9 | } from 'lucide-react' 10 | import { useCallback, useEffect, useRef, useState } from 'react' 11 | import { Button } from '../ui/button' 12 | import { 13 | ContextMenu, 14 | ContextMenuContent, 15 | ContextMenuItem, 16 | ContextMenuTrigger 17 | } from '../ui/context-menu' 18 | import { Input } from '../ui/input' 19 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table' 20 | 21 | export interface FileTableRow { 22 | name: string 23 | type: 'file' | 'folder' 24 | path: string 25 | size?: string 26 | modified?: string 27 | } 28 | 29 | interface FileGridProps { 30 | items: FileTableRow[] 31 | showParentFolder?: boolean 32 | onItemClick: (item: FileTableRow) => Promise | void 33 | onParentClick: () => Promise | void 34 | isLoading?: boolean 35 | currentPath: string 36 | filter?: string 37 | onFilterChange: (filter: string) => void 38 | } 39 | 40 | export function FileTable({ 41 | items, 42 | showParentFolder, 43 | onItemClick, 44 | onParentClick, 45 | isLoading, 46 | currentPath, 47 | filter = '', 48 | onFilterChange 49 | }: FileGridProps) { 50 | const [isCreatingFolder, setIsCreatingFolder] = useState(false) 51 | const [isCreatingFile, setIsCreatingFile] = useState(false) 52 | const [newItemName, setNewItemName] = useState('') 53 | const [localFilter, setLocalFilter] = useState(filter) 54 | const [isRenaming, setIsRenaming] = useState(null) 55 | const [renamingValue, setRenamingValue] = useState('') 56 | const filterTimeoutRef = useRef() 57 | 58 | useEffect(() => { 59 | setIsCreatingFolder(false) 60 | setIsCreatingFile(false) 61 | setNewItemName('') 62 | setIsRenaming(null) 63 | setRenamingValue('') 64 | }, [currentPath]) 65 | 66 | useEffect(() => { 67 | setLocalFilter(filter) 68 | }, [filter]) 69 | 70 | const handleFilterChange = useCallback( 71 | (value: string) => { 72 | setLocalFilter(value) 73 | if (filterTimeoutRef.current) { 74 | clearTimeout(filterTimeoutRef.current) 75 | } 76 | filterTimeoutRef.current = setTimeout(() => { 77 | onFilterChange(value) 78 | }, 300) 79 | }, 80 | [onFilterChange] 81 | ) 82 | 83 | useEffect(() => { 84 | return () => { 85 | if (filterTimeoutRef.current) { 86 | clearTimeout(filterTimeoutRef.current) 87 | } 88 | } 89 | }, []) 90 | 91 | const handleCreateFolder = useCallback(async () => { 92 | if (!newItemName) return 93 | const newPath = await window.api.joinPath(currentPath, newItemName) 94 | await window.api.mkdir(newPath) 95 | setIsCreatingFolder(false) 96 | setNewItemName('') 97 | }, [currentPath, newItemName]) 98 | 99 | const handleCreateFile = useCallback(async () => { 100 | if (!newItemName) return 101 | const newPath = await window.api.joinPath(currentPath, newItemName) 102 | await window.api.writeFile(newPath, '') 103 | setIsCreatingFile(false) 104 | setNewItemName('') 105 | }, [currentPath, newItemName]) 106 | 107 | const handleKeyDown = useCallback( 108 | async (e: React.KeyboardEvent) => { 109 | if (e.key === 'Enter') { 110 | if (isCreatingFolder) await handleCreateFolder() 111 | if (isCreatingFile) await handleCreateFile() 112 | if (isRenaming) { 113 | const oldPath = isRenaming 114 | const newPath = await window.api.joinPath(currentPath, renamingValue) 115 | try { 116 | await window.api.rename(oldPath, newPath) 117 | setIsRenaming(null) 118 | setRenamingValue('') 119 | } catch (error) { 120 | console.error('Failed to rename:', error) 121 | // TODO: Show error toast 122 | } 123 | } 124 | } else if (e.key === 'Escape') { 125 | setIsCreatingFolder(false) 126 | setIsCreatingFile(false) 127 | setNewItemName('') 128 | setIsRenaming(null) 129 | setRenamingValue('') 130 | } 131 | }, 132 | [ 133 | isCreatingFolder, 134 | isCreatingFile, 135 | isRenaming, 136 | currentPath, 137 | renamingValue, 138 | handleCreateFolder, 139 | handleCreateFile 140 | ] 141 | ) 142 | 143 | const handleRename = useCallback((item: FileTableRow) => { 144 | setIsRenaming(item.path) 145 | setRenamingValue(item.name) 146 | }, []) 147 | 148 | const handleDelete = useCallback(async (item: FileTableRow) => { 149 | try { 150 | await window.api.deleteFile(item.path) 151 | } catch (error) { 152 | console.error('Failed to delete:', error) 153 | // TODO: Show error toast 154 | } 155 | }, []) 156 | 157 | const filteredItems = items 158 | .filter( 159 | (item) => 160 | !item.name.startsWith('.') && item.name.toLowerCase().includes(localFilter.toLowerCase()) 161 | ) 162 | .sort((a, b) => { 163 | // Sort folders before files 164 | if (a.type === 'folder' && b.type === 'file') return -1 165 | if (a.type === 'file' && b.type === 'folder') return 1 166 | // Then sort alphabetically 167 | return a.name.localeCompare(b.name) 168 | }) 169 | 170 | return ( 171 |
172 |
173 |
174 | 175 | handleFilterChange(e.target.value)} 178 | placeholder="Filter files..." 179 | className="h-8" 180 | /> 181 |
182 | 194 | 206 |
207 | 208 | 209 | 210 | Name 211 | Type 212 | Size 213 | Modified 214 | 215 | 216 | 217 | {isLoading ? ( 218 | Array.from({ length: 10 }).map((_, i) => ( 219 | 220 | 221 |   222 | 223 | 224 | )) 225 | ) : ( 226 | <> 227 | {showParentFolder && ( 228 | 229 | 230 | 231 | .. 232 | 233 | Folder 234 | - 235 | - 236 | 237 | )} 238 | {(isCreatingFolder || isCreatingFile) && ( 239 | 240 | 241 | {isCreatingFolder ? ( 242 | 243 | ) : ( 244 | 245 | )} 246 | setNewItemName(e.target.value)} 249 | onKeyDown={handleKeyDown} 250 | placeholder={`Enter ${isCreatingFolder ? 'folder' : 'file'} name...`} 251 | autoFocus 252 | className="h-8" 253 | /> 254 | 255 | 256 | )} 257 | {filteredItems.map((item) => ( 258 | 259 | 260 | { 263 | if (isRenaming !== item.path) { 264 | onItemClick(item) 265 | } 266 | }} 267 | > 268 | 269 | {isRenaming === item.path ? ( 270 | <> 271 | {item.type === 'folder' ? ( 272 | 273 | ) : ( 274 | 275 | )} 276 | setRenamingValue(e.target.value)} 279 | onKeyDown={handleKeyDown} 280 | autoFocus 281 | className="h-8" 282 | onClick={(e) => e.stopPropagation()} 283 | /> 284 | 285 | ) : ( 286 | <> 287 | {item.type === 'folder' ? ( 288 | 289 | ) : ( 290 | 291 | )} 292 | {item.name} 293 | 294 | )} 295 | 296 | {item.type === 'folder' ? 'Folder' : 'File'} 297 | {item.size || '-'} 298 | {item.modified || '-'} 299 | 300 | 301 | 302 | handleRename(item)} className="gap-2"> 303 | 304 | Rename 305 | 306 | handleDelete(item)} 308 | className="gap-2 text-destructive focus:text-destructive" 309 | > 310 | 311 | Delete 312 | 313 | 314 | 315 | ))} 316 | 317 | )} 318 | 319 |
320 |
321 | ) 322 | } 323 | -------------------------------------------------------------------------------- /src/renderer/src/components/composites/panel-drag-overlay.tsx: -------------------------------------------------------------------------------- 1 | import { DragOverlay } from '@dnd-kit/core' 2 | import { LucideIcon } from 'lucide-react' 3 | import { ViewName } from '../../stock/Views' 4 | 5 | interface PanelDragOverlayProps { 6 | name?: ViewName 7 | Icon?: LucideIcon 8 | } 9 | 10 | export function PanelDragOverlay({ name, Icon }: PanelDragOverlayProps) { 11 | if (!name || !Icon) return null 12 | 13 | return ( 14 | 15 |
16 | 17 | {name.charAt(0).toUpperCase() + name.slice(1)} 18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/renderer/src/components/composites/panel-drop-zones.tsx: -------------------------------------------------------------------------------- 1 | import { SplitDirection } from '@devbookhq/splitter' 2 | import { useDroppable } from '@dnd-kit/core' 3 | import { cn } from '../../lib/utils' 4 | 5 | type Corner = 'tl' | 'tr' | 'bl' | 'br' 6 | 7 | interface DropZoneProps { 8 | id: string 9 | isOver: boolean 10 | className?: string 11 | roundedCorners?: Corner[] 12 | } 13 | 14 | function DropZone({ id, isOver, className, roundedCorners = [] }: DropZoneProps) { 15 | const { setNodeRef } = useDroppable({ id }) 16 | 17 | const roundedClasses = { 18 | 'rounded-tl-lg': roundedCorners.includes('tl'), 19 | 'rounded-tr-lg': roundedCorners.includes('tr'), 20 | 'rounded-bl-lg': roundedCorners.includes('bl'), 21 | 'rounded-br-lg': roundedCorners.includes('br') 22 | } 23 | 24 | return ( 25 |
34 | ) 35 | } 36 | 37 | interface PanelDropZonesProps { 38 | viewId: string 39 | activeDropId?: string 40 | isDragging?: boolean 41 | draggedId?: string 42 | } 43 | 44 | export function PanelDropZones({ 45 | viewId, 46 | activeDropId, 47 | isDragging, 48 | draggedId 49 | }: PanelDropZonesProps) { 50 | // Don't render anything if: 51 | // - nothing is being dragged 52 | // - or if we're dragging over ourselves 53 | if (!isDragging || draggedId === viewId) return null 54 | 55 | const dropZones = [ 56 | { 57 | id: `${viewId}:left`, 58 | className: 'left-0 top-0 w-1/2 h-full', 59 | direction: SplitDirection.Horizontal, 60 | insertAt: 'before' as const, 61 | roundedCorners: ['tl', 'bl'] as Corner[] 62 | }, 63 | { 64 | id: `${viewId}:right`, 65 | className: 'right-0 top-0 w-1/2 h-full', 66 | direction: SplitDirection.Horizontal, 67 | insertAt: 'after' as const, 68 | roundedCorners: ['tr', 'br'] as Corner[] 69 | }, 70 | { 71 | id: `${viewId}:top`, 72 | className: 'left-0 top-0 w-full h-1/2', 73 | direction: SplitDirection.Vertical, 74 | insertAt: 'before' as const, 75 | roundedCorners: ['tl', 'tr'] as Corner[] 76 | }, 77 | { 78 | id: `${viewId}:bottom`, 79 | className: 'left-0 bottom-0 w-full h-1/2', 80 | direction: SplitDirection.Vertical, 81 | insertAt: 'after' as const, 82 | roundedCorners: ['bl', 'br'] as Corner[] 83 | } 84 | ] 85 | 86 | return ( 87 |
88 | {dropZones.map((zone) => ( 89 | 96 | ))} 97 |
98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/renderer/src/components/composites/panel-title-bar.tsx: -------------------------------------------------------------------------------- 1 | import { SplitDirection } from '@devbookhq/splitter' 2 | import { PanelActionButtons } from './panel-title-bar/action-buttons' 3 | import { PanelNavButtons } from './panel-title-bar/nav-buttons' 4 | 5 | import { LucideIcon } from 'lucide-react' 6 | import { ViewProps } from '../../stock/Views' 7 | import { PanelTitle } from './panel-title-bar/title' 8 | 9 | interface PanelTitleBarProps { 10 | viewId: string 11 | name: keyof ViewProps 12 | Icon: LucideIcon 13 | onSplit: (direction: SplitDirection) => void 14 | onClose: () => void 15 | isActive: boolean 16 | canSplitVertical: boolean 17 | canSplitHorizontal: boolean 18 | } 19 | 20 | export function PanelTitleBar({ 21 | viewId, 22 | name, 23 | Icon, 24 | onSplit, 25 | onClose, 26 | isActive, 27 | canSplitVertical, 28 | canSplitHorizontal 29 | }: PanelTitleBarProps) { 30 | return ( 31 |
32 | 33 | 34 | 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/src/components/composites/panel-title-bar/action-buttons.tsx: -------------------------------------------------------------------------------- 1 | import { SplitDirection } from '@devbookhq/splitter' 2 | import { MoreVertical, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react' 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuTrigger 8 | } from '../../ui/dropdown-menu' 9 | 10 | interface PanelActionButtonsProps { 11 | onSplit: (direction: SplitDirection) => void 12 | onClose: () => void 13 | canSplitVertical?: boolean 14 | canSplitHorizontal?: boolean 15 | } 16 | 17 | export function PanelActionButtons({ 18 | onSplit, 19 | onClose, 20 | canSplitVertical, 21 | canSplitHorizontal 22 | }: PanelActionButtonsProps) { 23 | return ( 24 |
25 | 35 | 36 | 37 | 40 | 41 | 42 | { 44 | e.stopPropagation() 45 | onSplit(SplitDirection.Horizontal) 46 | }} 47 | disabled={!canSplitHorizontal} 48 | className="gap-2" 49 | > 50 | 51 | Split Horizontally 52 | 53 | 54 | 55 | 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/src/components/composites/panel-title-bar/nav-buttons.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeft, ArrowRight } from 'lucide-react' 2 | import { useView } from '../../../contexts/ViewContext' 3 | 4 | export function PanelNavButtons() { 5 | const { canGoBack, canGoForward, goBack, goForward } = useView() 6 | 7 | return ( 8 |
9 | 19 | 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/renderer/src/components/composites/panel-title-bar/title.tsx: -------------------------------------------------------------------------------- 1 | import { useDraggable } from '@dnd-kit/core' 2 | import { LucideIcon } from 'lucide-react' 3 | import { ViewName } from '../../../stock/Views' 4 | 5 | interface PanelTitleProps { 6 | viewId: string 7 | name: ViewName 8 | Icon: LucideIcon 9 | isActive?: boolean 10 | } 11 | 12 | export function PanelTitle({ viewId, name, Icon, isActive }: PanelTitleProps) { 13 | const { attributes, listeners, setNodeRef, transform } = useDraggable({ 14 | id: viewId 15 | }) 16 | 17 | return ( 18 |
19 | 28 | 29 | {name.charAt(0).toUpperCase() + name.slice(1)} 30 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/src/components/composites/path-breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { Brain } from 'lucide-react' 2 | import { useEffect, useState } from 'react' 3 | import { 4 | Breadcrumb, 5 | BreadcrumbItem, 6 | BreadcrumbLink, 7 | BreadcrumbList, 8 | BreadcrumbSeparator 9 | } from '../ui/breadcrumb' 10 | 11 | interface PathBreadcrumbsProps { 12 | path: string 13 | onBreadcrumbClick: (path: string) => Promise | void 14 | } 15 | 16 | export function PathBreadcrumbs({ path, onBreadcrumbClick }: PathBreadcrumbsProps) { 17 | const [homePath, setHomePath] = useState('') 18 | const [homeFolderName, setHomeFolderName] = useState('') 19 | 20 | useEffect(() => { 21 | const initHomePath = async () => { 22 | const home = await window.api.getHomePath() 23 | setHomePath(home) 24 | // Extract the last folder name from the path 25 | const folderName = home.split('/').filter(Boolean).pop() || '' 26 | setHomeFolderName(folderName) 27 | } 28 | initHomePath() 29 | }, []) 30 | 31 | // Get segments relative to home path 32 | const getRelativeSegments = () => { 33 | if (!homePath || !path.startsWith(homePath)) return [] 34 | const relativePath = path.slice(homePath.length) 35 | return relativePath.split(/[/\\]/).filter(Boolean) 36 | } 37 | 38 | const segments = getRelativeSegments() 39 | 40 | const getPathUpToSegment = async (index: number) => { 41 | const segmentsUpToIndex = segments.slice(0, index + 1) 42 | return window.api.joinPath(homePath, ...segmentsUpToIndex) 43 | } 44 | 45 | return ( 46 | 47 | 48 | 49 | onBreadcrumbClick(homePath)} 52 | > 53 | 54 | {homeFolderName} 55 | 56 | 57 | {segments.map((segment, index) => ( 58 | 59 | / 60 | { 63 | const path = await getPathUpToSegment(index) 64 | onBreadcrumbClick(path) 65 | }} 66 | > 67 | {segment} 68 | 69 | 70 | ))} 71 | 72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/renderer/src/components/navigation-listener.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useWorkspace } from '../contexts/WorkspaceContext' 3 | 4 | export function NavigationListener() { 5 | const { canGoBack, canGoForward, goBack, goForward, activeViewId } = useWorkspace() 6 | 7 | useEffect(() => { 8 | const handleKeyDown = (event: KeyboardEvent) => { 9 | // Handle Alt+Left and Alt+Right for back/forward 10 | if (event.altKey) { 11 | if (event.key === 'ArrowLeft' && activeViewId && canGoBack(activeViewId)) { 12 | event.preventDefault() 13 | goBack(activeViewId) 14 | } else if (event.key === 'ArrowRight' && activeViewId && canGoForward(activeViewId)) { 15 | event.preventDefault() 16 | goForward(activeViewId) 17 | } 18 | } 19 | 20 | // Handle browser back/forward buttons (Cmd/Ctrl + [ or ]) 21 | if (event.metaKey || event.ctrlKey) { 22 | if (event.key === '[' && activeViewId && canGoBack(activeViewId)) { 23 | event.preventDefault() 24 | goBack(activeViewId) 25 | } else if (event.key === ']' && activeViewId && canGoForward(activeViewId)) { 26 | event.preventDefault() 27 | goForward(activeViewId) 28 | } 29 | } 30 | } 31 | 32 | // Handle mouse back/forward buttons 33 | const handleMouseBack = (event: MouseEvent) => { 34 | if (event.button === 4 || event.button === 8) { 35 | // Forward button 36 | event.preventDefault() 37 | if (activeViewId && canGoForward(activeViewId)) { 38 | goForward(activeViewId) 39 | } 40 | } else if (event.button === 3) { 41 | // Back button 42 | event.preventDefault() 43 | if (activeViewId && canGoBack(activeViewId)) { 44 | goBack(activeViewId) 45 | } 46 | } 47 | } 48 | 49 | window.addEventListener('keydown', handleKeyDown) 50 | window.addEventListener('mouseup', handleMouseBack) 51 | 52 | return () => { 53 | window.removeEventListener('keydown', handleKeyDown) 54 | window.removeEventListener('mouseup', handleMouseBack) 55 | } 56 | }, [canGoBack, canGoForward, goBack, goForward]) 57 | 58 | return null 59 | } 60 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as AccordionPrimitive from '@radix-ui/react-accordion' 3 | import { ChevronDown } from 'lucide-react' 4 | 5 | import { cn } from '@/lib/utils' 6 | 7 | const Accordion = AccordionPrimitive.Root 8 | 9 | const AccordionItem = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 14 | )) 15 | AccordionItem.displayName = 'AccordionItem' 16 | 17 | const AccordionTrigger = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, children, ...props }, ref) => ( 21 | 22 | svg]:rotate-180', 26 | className 27 | )} 28 | {...props} 29 | > 30 | {children} 31 | 32 | 33 | 34 | )) 35 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 36 | 37 | const AccordionContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 46 |
{children}
47 |
48 | )) 49 | 50 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 51 | 52 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 53 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as AvatarPrimitive from '@radix-ui/react-avatar' 2 | import * as React from 'react' 3 | 4 | import { cn } from '../../lib/utils' 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 15 | )) 16 | Avatar.displayName = AvatarPrimitive.Root.displayName 17 | 18 | const AvatarImage = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, ...props }, ref) => ( 22 | 27 | )) 28 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 29 | 30 | const AvatarFallback = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 42 | )) 43 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 44 | 45 | export { Avatar, AvatarFallback, AvatarImage } 46 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { cva, type VariantProps } from 'class-variance-authority' 3 | 4 | import { cn } from '@renderer/lib/utils' 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', 12 | secondary: 13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 14 | destructive: 15 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 16 | outline: 'text-foreground' 17 | } 18 | }, 19 | defaultVariants: { 20 | variant: 'default' 21 | } 22 | } 23 | ) 24 | 25 | export interface BadgeProps 26 | extends React.HTMLAttributes, 27 | VariantProps {} 28 | 29 | function Badge({ className, variant, ...props }: BadgeProps) { 30 | return
31 | } 32 | 33 | export { Badge, badgeVariants } 34 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from '@radix-ui/react-slot' 2 | import { ChevronRight, MoreHorizontal } from 'lucide-react' 3 | import * as React from 'react' 4 | import { cn } from '../../lib/utils' 5 | 6 | const Breadcrumb = React.forwardRef< 7 | HTMLElement, 8 | React.ComponentPropsWithoutRef<'nav'> & { 9 | separator?: React.ReactNode 10 | } 11 | >(({ ...props }, ref) =>