first commit
This commit is contained in:
72
CLAUDE.md
Normal file
72
CLAUDE.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project state
|
||||
|
||||
This is a **greenfield project**. No application code, build setup, or `package.json` exists yet — only a design handoff. The first real task is to scaffold the Electron app and port the prototype. Until then, treat the two handoff documents as the contract:
|
||||
|
||||
- **`DESIGN.md`** — functional/UX source of truth. Every panel, interaction, state, and edge case at the behavior level. Read this for *what the app does*.
|
||||
- **`design_handoff_helder_workbench/README.md`** — technical source of truth. Structure, design tokens, recommended stack, real-integration mechanics, and the suggested implementation order. Read this for *how to build it*.
|
||||
|
||||
The prototype in `design_handoff_helder_workbench/design/` (React 18 + Babel from CDN, all mock data) is a **visual/interaction reference only — do not ship it as-is**. The HTML is canonical for look and feel; `design/styles.css`'s `:root` block is the canonical design-token list. The `design/src/*.jsx` files map directly to the components to build, but their mock data (`data.js`) and simulated terminals/agent must be replaced with real integrations.
|
||||
|
||||
## What Helder is
|
||||
|
||||
A dark-only (no light mode, no theme toggle) Electron desktop code workbench for reviewing code written by an AI agent. One project per window. Four resizable columns left→right: **Source Control (git)**, **Explorer (file tree)**, **Editor (tabs + diff)**, **Right column (Claude agent terminal stacked over a shell terminal)**. Plus a top title bar and bottom status bar. The defining feature is the **Copy reference / Pass on to Agent** flow that pushes `path:line` references into the agent's input.
|
||||
|
||||
## Recommended stack (no codebase exists — follow README)
|
||||
|
||||
- Electron (latest stable), main + renderer + preload bridge with `contextIsolation: true`.
|
||||
- Renderer: React 18 + TypeScript + Vite (`electron-vite` scaffold). Prototype is already React, so component structure ports directly.
|
||||
- Syntax highlighting: Prism 1.29 (or swap to Shiki/CodeMirror 6; token→color mapping is documented in README).
|
||||
- Fonts: UI = system stack; code/mono = **JetBrains Mono bundled locally** (never Google Fonts CDN in Electron).
|
||||
|
||||
## Architecture rules and gotchas (these will bite if ignored)
|
||||
|
||||
- **Renderer never touches the filesystem, git, or PTYs directly.** All FS (`fs` + `chokidar`), git (`git` / `simple-git`), search (`rg` + fuzzy), terminals (`node-pty` + `xterm.js`), and clipboard go through the main process via IPC / the preload bridge. The prototype keeps all state in the top `App` component; in the real app, lift FS/git/terminal state into main and stream over IPC.
|
||||
- **Prism PHP load order:** `prism-php` requires `prism-markup-templating` to be loaded **first**, or every `Prism.highlight` call throws and silently falls back to plain text.
|
||||
- **Pass on to Agent uses bracketed paste.** Write inserts to the agent PTY wrapped in `\x1b[200~ … \x1b[201~` so the `claude` CLI treats it as *pasted, unsubmitted* input. Insert must never submit — it lands as a new line so the user can stack several references before sending.
|
||||
- **The four diff view modes (Original / Updated / Diff / Split) all derive from one original-text + updated-text pair per changed file.** The prototype computes this with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output but keep the same four derived views and the same color language everywhere: **red = removed/changed-from, green = added/changed-to**, syntax highlighting on in all modes.
|
||||
- **The agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery.
|
||||
- **Chrome budget:** title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal.
|
||||
|
||||
## Confirmed decisions (the "Open assumptions" in DESIGN.md are resolved — do not re-ask)
|
||||
|
||||
- Search overlay layout: **content matches left (70%), file-name matches right (30%)** — keep as designed.
|
||||
- Tabs **show an unsaved indicator** (a dot in place of the close control) because auto-save defaults off (`editor.autoSave`).
|
||||
- Explorer right-click offers a **file-level Copy reference** (project-relative path only), consistent with the editor's Copy reference — in scope.
|
||||
|
||||
## Scope boundaries (this version)
|
||||
|
||||
- **Git covers staging, unstaging, committing, and discarding only.** Push, pull, fetch, and branch switching are explicitly out of scope. The branch summary bar and status bar are display-only.
|
||||
- **One agent terminal and one shell terminal** — no additional tabs or sessions.
|
||||
- The breadcrumb and status-bar items are **display only** (not clickable, do not navigate).
|
||||
- **Discard is the only destructive git action** and must confirm first (`git.confirmDiscard`, default on). Staging/unstaging do not confirm by default.
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are project-scoped, living in a `.helder/` folder in the opened project's root:
|
||||
|
||||
- `.helder/config.json` — sparse; only user-overridden values.
|
||||
- `.helder/config.default.json` — full defaults, **regenerated on launch** from built-in defaults (live documentation of every setting; the app never reads user edits from it).
|
||||
- Effective value = `config.json` if present, else `config.default.json`, merged key by key.
|
||||
- `.helder/theme.css` — custom CSS theme applied over the built-in dark theme; **code font and font size live here**, not in the config files.
|
||||
|
||||
## Design tokens
|
||||
|
||||
Canonical source is the `:root` block in `design_handoff_helder_workbench/design/styles.css`. Surfaces are cool charcoal (`--bg-0` editor `#16171a` → `--bg-3` headers/tabs `#23262b`); single cool-blue accent `--accent #4d8dff`; git status `--add #5cbd6b` / `--del #e0696a` / `--mod #d8a85c` / `--ren #5aa6d6`. File-type icons are 15×15 monogram chips (no brand logos). Recreate UI icons as a small inline-SVG set (or Lucide), keeping the monogram chips for file types. Respect `prefers-reduced-motion`; keep motion subtle.
|
||||
|
||||
## Suggested implementation order (from README)
|
||||
|
||||
1. Electron shell + frameless dark window; port tokens to CSS vars; bundle JetBrains Mono.
|
||||
2. Static layout: four resizable columns + title/status bars.
|
||||
3. Real file tree + open files into tabs (read-only) with Prism highlighting.
|
||||
4. Git panel from `git status` (read-only) → staging + commit → the four diff modes + Split.
|
||||
5. Search (ripgrep + fuzzy).
|
||||
6. Terminals via node-pty + xterm.js; run `claude` in the agent pane.
|
||||
7. Copy reference + Pass-on-to-Agent (clipboard + bracketed-paste into the agent PTY).
|
||||
|
||||
## Commands
|
||||
|
||||
No build/lint/test commands exist yet. Once the Electron + Vite toolchain is scaffolded, document the real `dev` / `build` / `lint` / `test` commands here, replacing this note.
|
||||
251
DESIGN.md
Normal file
251
DESIGN.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Helder Functional Design
|
||||
|
||||
This document describes how Helder behaves: every panel, interaction, and state, at the functional and UX level. It does not cover technical architecture. It is written to match the agreed user interface exactly.
|
||||
|
||||
Helder is a dark, AI-first desktop code editor for reviewing and navigating code written by an AI agent. It opens one project per window and has no light mode and no theme toggle.
|
||||
|
||||
---
|
||||
|
||||
## 1. Window layout
|
||||
|
||||
The window has three horizontal regions stacked top to bottom:
|
||||
|
||||
1. A top bar.
|
||||
2. The main body, split into four resizable columns.
|
||||
3. A bottom status bar.
|
||||
|
||||
The four columns of the body, left to right, are:
|
||||
|
||||
1. **Source Control**
|
||||
2. **Explorer** (file tree)
|
||||
3. **Editor** (tabs and code)
|
||||
4. **Agent and Terminal**, a single column split into two stacked panes: the Claude agent pane on top and a shell terminal below it.
|
||||
|
||||
Every boundary between panes is a draggable splitter. The vertical splitters between the four columns adjust their widths. The horizontal splitter between the agent pane and the terminal pane adjusts their heights. Splitter positions persist across launches. Panes have sensible minimum widths and heights so they cannot be collapsed to nothing by dragging.
|
||||
|
||||
The interface is dark only. Code is always syntax highlighted, in every panel and every view.
|
||||
|
||||
---
|
||||
|
||||
## 2. Top bar
|
||||
|
||||
The top bar contains, left to right:
|
||||
|
||||
- The Helder mark and name.
|
||||
- A breadcrumb showing the path to the active file, for example `console / src / Http / Controller / UserController.php`. The breadcrumb is **display only** for now. It is not clickable and does not navigate.
|
||||
- On the right, two controls: **Go to File** (`Cmd/Ctrl+P`) and **Search** (`Cmd/Ctrl+F`). Both open overlays described in section 9.
|
||||
|
||||
---
|
||||
|
||||
## 3. Source Control panel
|
||||
|
||||
The leftmost column. It shows the working state of the project's git repository and is where staging and committing happen.
|
||||
|
||||
### Header
|
||||
|
||||
A `SOURCE CONTROL` header with a count badge showing the total number of changed files (staged plus unstaged).
|
||||
|
||||
### Commit area
|
||||
|
||||
Directly under the header:
|
||||
|
||||
- A single-line **commit message** input.
|
||||
- A **Commit** button whose label includes the staged count, for example `Commit 2`.
|
||||
- The Commit button is enabled only when there is at least one staged change and the message field is not empty. Clicking it commits the staged changes with the typed message, clears the message field, and moves the committed files out of the staged section. The working tree updates accordingly.
|
||||
|
||||
### Sections
|
||||
|
||||
Two stacked sections, each with its own header and count:
|
||||
|
||||
1. **STAGED CHANGES (n)** on top, listing files that are staged.
|
||||
2. **CHANGES (n)** below, listing unstaged working-tree changes (added, modified, deleted).
|
||||
|
||||
### Row anatomy
|
||||
|
||||
Each row, identical in both sections:
|
||||
|
||||
- A status letter on the far left: `A` added, `M` modified, `D` deleted, each in its own color (added green, modified amber, deleted red).
|
||||
- A file-type icon, then the file name.
|
||||
- The dimmed relative path, aligned to the right of the name.
|
||||
- Change counts at the far right: additions in green (`+N`) and deletions in red (`-N`).
|
||||
- Deleted files are shown with the file name struck through and dimmed.
|
||||
- Rows have default, hover, and selected states. The selected row is the file currently open in the editor.
|
||||
|
||||
### Interactions
|
||||
|
||||
- **Left-click** a row opens that file in the editor.
|
||||
- **Right-click** a row opens a context menu, designed to grow over time:
|
||||
- In CHANGES (unstaged): **Stage file**, and **Discard changes**.
|
||||
- In STAGED: **Unstage file**.
|
||||
- **Hover quick action**: on hover, an unstaged row shows a `+` to stage it in one click, and a staged row shows a `-` to unstage it.
|
||||
- Optional section actions: **Stage all** on the CHANGES header and **Unstage all** on the STAGED header.
|
||||
- **Discard** is a destructive action. It asks for confirmation first (controlled by `git.confirmDiscard`, default on). Discarding reverts a modified file to its committed state and restores a deleted file.
|
||||
- Staging and unstaging do not ask for confirmation by default (`git.confirmStage` and `git.confirmUnstage`, default off).
|
||||
|
||||
### Branch summary bar
|
||||
|
||||
At the very bottom of this column, a bar shows the current branch name and the total line changes across the working tree, for example `feat/payments-balance +50 -38`. This bar is **display only**. It does not switch branches.
|
||||
|
||||
### Empty states
|
||||
|
||||
- When nothing is staged, the STAGED section shows a dimmed line: `No staged changes`.
|
||||
- When the working tree is clean, the CHANGES section shows a dimmed line: `Working tree clean`.
|
||||
|
||||
Scope note: git in this version covers staging, unstaging, committing, and discarding. Push, pull, fetch, and branch switching are not part of this version.
|
||||
|
||||
---
|
||||
|
||||
## 4. Explorer panel
|
||||
|
||||
The second column. A standard project file tree.
|
||||
|
||||
- An `EXPLORER` header.
|
||||
- Folders expand and collapse. Files and folders use type icons.
|
||||
- Files that have changes show the same status letter as the Source Control panel (`A`, `M`, `D`) on the right of their row.
|
||||
- **Left-click** a file opens it in the editor. The selected file is highlighted.
|
||||
- The tree hides paths matched by `files.exclude` and, when `files.followGitignore` is on, paths ignored by git.
|
||||
- **Right-click** a file offers **Copy reference**, which copies the file's project-relative path to the clipboard (the file-level form of the editor's Copy reference).
|
||||
|
||||
---
|
||||
|
||||
## 5. Editor
|
||||
|
||||
The third and widest column. Tabs on top, a view toolbar under them, and the code below.
|
||||
|
||||
### Tabs
|
||||
|
||||
- Each open file is a tab showing its type icon and name.
|
||||
- A tab opened on a changed file shows a small **DIFF** badge.
|
||||
- A tab shows an unsaved indicator (a dot in place of the close control) when the file has unsaved edits. This matters because auto-save is off by default (`editor.autoSave`).
|
||||
- Each tab has a close control (`x`). Clicking a tab switches to it. Closing a tab with unsaved edits prompts to save or discard.
|
||||
|
||||
### View toolbar
|
||||
|
||||
A row under the tabs:
|
||||
|
||||
- On the left, a status summary for the active file, for example `Modified +6 -2`.
|
||||
- On the right, a four-segment control: **Original | Updated | Diff | Split**. The active segment is highlighted. This control appears only for files that have changes relative to their committed state. Its starting mode follows `git.defaultDiffMode` (default `Diff`).
|
||||
|
||||
### View modes
|
||||
|
||||
All four are presentations of the same change set for the file:
|
||||
|
||||
1. **Original**: the file as it was before the change, read-only. Changed and removed lines get a red bar in the left gutter. No inline plus or minus markers.
|
||||
2. **Updated**: the current, editable version of the file. Added and changed lines get a green bar in the left gutter.
|
||||
3. **Diff**: a single pane, unified inline diff. Removed lines are red with a `-`, added lines are green with a `+`, shown in sequence.
|
||||
4. **Split**: the editor expands to full screen, covering the other columns. The original file is on the left and the updated file is on the right, lines aligned. Removals are marked red on the left, additions green on the right. `Esc`, or a collapse control in the corner, returns to the normal layout and the previously active mode.
|
||||
|
||||
Shared rules: red always means removed or changed-from, green always means added or changed-to. Syntax highlighting stays on in all four modes. Line numbers follow `editor.lineNumbers` (default absolute).
|
||||
|
||||
### Editing behavior
|
||||
|
||||
- A file with no changes opens directly in a normal editable view with no view-mode control, since there is nothing to diff.
|
||||
- Editing follows the editor settings: indentation, indent width, trim trailing whitespace, insert final newline, word wrap, indent guides, whitespace rendering, and active-line highlight, all read from configuration (section 10).
|
||||
|
||||
### Right-click in code
|
||||
|
||||
Right-clicking inside the code area, with or without a selection, shows a context menu with exactly two items:
|
||||
|
||||
1. **Copy reference**: copies a reference to the clicked location to the clipboard. With no selection it is path plus single line, for example `src/Http/Controller/UserController.php:42`. With a selection it is path plus start and end line, for example `src/Http/Controller/UserController.php:42-58`. The path is project-relative.
|
||||
2. **Pass on to Agent**: opens a small inline input popup anchored near the click or selection, with a single optional text field. On confirm (`Enter`), it composes one line, the typed text followed by the same reference, and inserts it into the agent pane's input. The insert does **not** submit. It lands as a new, unsubmitted line and the cursor sits on a fresh line, so the action can be repeated to stack several references before the user sends. `Esc` cancels with no insert. An empty field passes the bare reference.
|
||||
|
||||
### File-state behavior in the view modes
|
||||
|
||||
- **Added file**: only the Updated content exists. Original is empty, Diff shows everything as added (green), Split shows an empty left and the file on the right.
|
||||
- **Deleted file**: only the Original content exists. Updated is empty, Diff shows everything as removed (red), Split shows the file on the left and an empty right.
|
||||
- **Binary or unsupported file**: cannot be shown as editable text. The editor shows a short placeholder stating the file cannot be displayed, and the view-mode control is not shown.
|
||||
|
||||
---
|
||||
|
||||
## 6. Agent pane
|
||||
|
||||
The top pane of the fourth column.
|
||||
|
||||
- A `claude` header with an `agent session` label.
|
||||
- It is a single terminal running the configured agent command. On launch it auto-runs that command when `ai.autoLaunch` is on (default), using `ai.command` (default `claude`).
|
||||
- The pane shows the live agent session and has an input line at the bottom.
|
||||
- It receives the **Pass on to Agent** inserts from the editor: text arrives in the input as content, never as a submitted command, so the user can stack several and send when ready.
|
||||
- It is one terminal only. There are no additional agent tabs or sessions in this version.
|
||||
|
||||
---
|
||||
|
||||
## 7. Terminal pane
|
||||
|
||||
The bottom pane of the fourth column.
|
||||
|
||||
- A header showing the shell and working directory, for example `zsh` and the project path.
|
||||
- It is a single shell terminal. It uses the operating system default shell, or the override in `terminal.shell` when set.
|
||||
- It behaves as a normal terminal for that shell.
|
||||
- It is one terminal only. There are no additional terminal tabs in this version.
|
||||
|
||||
---
|
||||
|
||||
## 8. Status bar
|
||||
|
||||
A bar across the bottom of the window. All items are **display only** for now; none are clickable.
|
||||
|
||||
- On the left: the current branch and the working-tree line totals, for example `feat/payments-balance +50 -38`.
|
||||
- On the right: cursor position (`Ln, Col`), indentation (`Spaces: 4`), encoding (`UTF-8`), language (`PHP`), and the active view mode (`Diff`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Global interactions
|
||||
|
||||
### Go to File (`Cmd/Ctrl+P`)
|
||||
|
||||
Opens a fuzzy file-open overlay. The user types part of a file name or path, the list narrows as they type, and selecting a result opens that file in the editor. `Esc` closes the overlay.
|
||||
|
||||
### Search (`Cmd/Ctrl+F`)
|
||||
|
||||
Opens a modal search overlay that searches **both** file contents and file names at once. The overlay covers 90 percent of the application width and is centered over a dimmed background.
|
||||
|
||||
Inside the overlay:
|
||||
|
||||
- A query field at the top.
|
||||
- Two result columns below:
|
||||
- **Left column, 70 percent width**: matches found inside files (content search), grouped by file, each match shown as the matching line with the query highlighted in context.
|
||||
- **Right column, 30 percent width**: files whose name or path matches the query.
|
||||
- Selecting a content result opens the file and jumps to that line. Selecting a file-name result opens that file.
|
||||
- When nothing matches, each column shows a brief no-results message.
|
||||
- `Esc` closes the overlay. The respected excludes are the same as the Explorer (`files.exclude` and, when on, gitignore).
|
||||
|
||||
### Pane resizing
|
||||
|
||||
Dragging any splitter resizes the adjacent panes. Positions persist across launches.
|
||||
|
||||
### Session restore
|
||||
|
||||
When `session.restoreOnLaunch` is on (default), reopening a project restores the previously open tabs, the active tab, and the pane and splitter layout.
|
||||
|
||||
---
|
||||
|
||||
## 10. Configuration and theming
|
||||
|
||||
Settings and theming are project-scoped and live in a `.helder` folder in the project root.
|
||||
|
||||
- **`.helder/config.json`**: the user's settings. It is sparse and holds only the values the user has overridden.
|
||||
- **`.helder/config.default.json`**: the full set of defaults, with a default for every value. Helder regenerates this file on launch from its built-in defaults, so it always matches the installed version and serves as live documentation of every available setting. The app never reads user edits from this file.
|
||||
- **Effective value** of any setting: the value from `config.json` if present, otherwise the value from `config.default.json`, merged key by key.
|
||||
- **`.helder/theme.css`**: a custom theme expressed as CSS, applied on top of the built-in dark theme. The code font and font size live here, not in the config files.
|
||||
|
||||
Settings that affect behavior described above include indentation and editor display, file and search excludes, the default diff mode, git confirmation prompts, the terminal shell override, the agent auto-launch and command, and session restore.
|
||||
|
||||
---
|
||||
|
||||
## 11. States and edge cases summary
|
||||
|
||||
- **No project open**: the window shows an empty state inviting the user to open a project folder.
|
||||
- **Clean working tree**: Source Control shows its empty-section messages; the Explorer still shows the full tree.
|
||||
- **Unchanged file open**: normal editable view, no view-mode control.
|
||||
- **Added or deleted file**: view modes behave as described in section 5.
|
||||
- **Binary or unsupported file**: placeholder, no view-mode control.
|
||||
- **Search with no matches**: per-column no-results messages.
|
||||
- **Agent command not available**: the agent pane is a terminal, so a missing or failing command surfaces as ordinary terminal output in that pane.
|
||||
|
||||
---
|
||||
|
||||
## Open assumptions to confirm
|
||||
|
||||
- In the Search overlay, content matches are on the left (70 percent) and file-name matches on the right (30 percent). Confirm this assignment, or swap the two sides.
|
||||
- Tabs show an unsaved indicator because auto-save defaults to off. Confirm that behavior is wanted.
|
||||
- The Explorer right-click offers a file-level Copy reference (path only), consistent with the editor's Copy reference. Confirm this is in scope.
|
||||
271
design_handoff_helder_workbench/README.md
Normal file
271
design_handoff_helder_workbench/README.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Handoff: Helder — AI Code Workbench (Electron)
|
||||
|
||||
## Overview
|
||||
**Helder** is a desktop code workbench for a developer who reviews and works with code written by an AI agent. It is a dense, four-column IDE-style window optimized for an ultrawide monitor (designed at **3440×1440**, but fully fluid down to ~1280px wide). It is **dark mode only** — there is intentionally no light theme and no theme toggle.
|
||||
|
||||
The core jobs the app supports:
|
||||
- Fast project navigation + a single search that covers **both file contents and file names**.
|
||||
- A Git review surface (commit box, staged list, changes list, per-file diff with four view modes).
|
||||
- An editor with tabs and syntax-colored code.
|
||||
- A live **Claude Code agent** terminal plus a normal shell terminal.
|
||||
- A right-click **"Copy reference"** / **"Pass on to Agent"** flow that pushes `path:line` references into the agent's input so the developer can quickly point the agent at code.
|
||||
|
||||
## About the Design Files
|
||||
The files in `design/` are a **design reference created in HTML/CSS/React-via-Babel** — a working prototype that shows the intended look, layout, and behavior. **They are not the production codebase and should not be shipped as-is.**
|
||||
|
||||
The task is to **recreate this design as a real Electron application**, using a proper build setup and the patterns below. The prototype loads React 18 + Babel from a CDN and stores everything in mock data; the real app should use a normal toolchain (see "Recommended Electron Stack"). Treat the HTML as the source of truth for *visual + interaction design*, and this README as the source of truth for *structure, tokens, and behavior*.
|
||||
|
||||
You can open `design/Helder - AI Code Workbench.html` directly in a browser to see and click the live prototype while building.
|
||||
|
||||
## Fidelity
|
||||
**High-fidelity (hifi).** Colors, typography, spacing, diff coloring, and interactions are final. Recreate the UI pixel-faithfully. All exact values are in the Design Tokens section and in `design/styles.css` (the prototype's `:root` block is the canonical token list).
|
||||
|
||||
---
|
||||
|
||||
## Recommended Electron Stack
|
||||
No target codebase exists yet, so choose a modern, conventional setup:
|
||||
- **Electron** (latest stable) with a **main** process and a **renderer**.
|
||||
- **Renderer:** React 18 + TypeScript + Vite (`electron-vite` is a good scaffold). The prototype is already React, so component structure ports directly.
|
||||
- **Syntax highlighting:** the prototype uses **Prism 1.29** (`prism-core` + `markup-templating`, `php`, `python`, `typescript`, `json`, `bash`, `markdown`). Keep Prism, or swap to **Shiki**/**CodeMirror 6** if you prefer; the token→color mapping is documented below. **Important Prism gotcha:** `prism-php` requires `prism-markup-templating` to be loaded **first**, or every `Prism.highlight` call throws and silently falls back to plain text.
|
||||
- **Fonts:** UI = system stack (`-apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`); code/mono = **JetBrains Mono** (bundle the font locally for offline use — do not rely on Google Fonts CDN in Electron).
|
||||
- **Real integrations to wire up (replacing the mock):**
|
||||
- File tree + file contents → real FS via the main process (`fs`, `chokidar` for watching). Never touch FS directly from the renderer; use IPC / a preload bridge with `contextIsolation: true`.
|
||||
- Git panel → shell out to `git` (or `simple-git`) for `status --porcelain`, staged/unstaged sets, `add`/`reset`, `commit`, and `diff`. The prototype computes diffs in JS with an LCS; in the real app prefer `git diff` output, but the four view modes still derive from an original/updated text pair per file.
|
||||
- Terminals → real PTYs via **node-pty** + **xterm.js**. The "Claude agent" pane is just a terminal that runs the `claude` CLI; the bottom pane is a normal shell. The prototype fakes both — see "Terminals".
|
||||
- Search → ripgrep (`rg`) for content search; a fast fuzzy matcher (e.g. `fzf`-style or `fuse.js`) for file-name search.
|
||||
- "Copy reference" → Electron `clipboard.writeText`.
|
||||
- "Pass on to Agent" → write into the agent terminal's PTY using **bracketed paste** (`\x1b[200~` … `\x1b[201~`) so the `claude` CLI treats it as pasted input and does NOT submit it. This is the real mechanism the prototype only simulates.
|
||||
|
||||
---
|
||||
|
||||
## Global Layout
|
||||
|
||||
Top-level vertical stack (`.app`, `height:100vh`, `display:flex; flex-direction:column`):
|
||||
|
||||
1. **Title bar** — `height: 36px`, fixed.
|
||||
2. **Workbench** — `flex: 1`, a horizontal flex row of four columns separated by draggable splitters.
|
||||
3. **Status bar** — `height: 23px`, fixed.
|
||||
|
||||
**Design principle the client asked for:** keep the chrome minimal — title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height so code and tools own the screen.
|
||||
|
||||
### Workbench columns (left → right)
|
||||
All columns are **horizontally resizable** by dragging the 5px splitter between them. The editor is the flex-grow column; the other three have explicit pixel widths with min/max clamps.
|
||||
|
||||
| # | Column | Default width | Min–Max | Notes |
|
||||
|---|--------|---------------|---------|-------|
|
||||
| 1 | **Source Control** (Git) | 232px | 160–460 | commit box + staged list + changes list |
|
||||
| 2 | **Explorer** (file tree) | 244px | 160–520 | VS Code-style tree, colored type icons |
|
||||
| 3 | **Editor** | flex:1 | min 240px | tabs + code/diff |
|
||||
| 4 | **Right column** | 444px | 280–780 | split vertically: agent terminal (top) + shell (bottom), draggable horizontal splitter, default top fraction 0.52 |
|
||||
|
||||
Splitter: 5px hit area, transparent; inner 1px line is `--border`, turning to `--accent` (0.55 alpha) on hover/drag. Vertical splitter cursor `col-resize`; horizontal `row-resize`.
|
||||
|
||||
---
|
||||
|
||||
## Screens / Components
|
||||
|
||||
### 1. Title bar (`.titlebar`)
|
||||
- Height 36px, background `--bg-3`, bottom border `--border`, horizontal padding 12px, items gap 14px.
|
||||
- Left → right:
|
||||
- **Traffic lights**: three 12px circles, gap 8px — red `#e0696a`, yellow `#d8a85c`, green `#5cbd6b`. (On macOS use the native frame instead; these are decorative in the web prototype.)
|
||||
- **Wordmark**: a small spark/diamond icon in `--accent`, then **`Helder`** (weight 600, `--fg-0`), an em-dash in `--fg-3`, then the project name (`console`) in `--fg-2`. Font 12px.
|
||||
- **Breadcrumb** of the active file path, monospace 11.5px, `--fg-3`, segments joined by ` › `; last segment `--fg-1`.
|
||||
- Spacer (flex:1).
|
||||
- **Search button**: `.tb-btn` — ghost button, 11.5px, `--fg-2`; search icon + "Search" + a `<kbd>⌘F</kbd>` chip. Hover → `--hover` bg, `--fg-0` text. (There is intentionally **no "Go to File" button** — search covers file names too.)
|
||||
|
||||
### 2. Source Control panel (`.col` #1)
|
||||
Order, top to bottom:
|
||||
|
||||
**a. Panel header** (`.phead`, height 30px): branch icon + "SOURCE CONTROL" (uppercase, 10.5px, letter-spacing .09em, `--fg-2`) + a count pill on the right showing the number of changed (uncommitted) files.
|
||||
|
||||
**b. Commit box** (`.commit-box`, padding 9px 10px, bottom border): a flex row, `align-items:flex-start`, gap 7px.
|
||||
- **Message field**: a 1-row auto-growing `<textarea>` (`.commit-input`), flex:1, bg `--bg-0`, 1px `--border-2` border, radius 7px, padding 7px 9px, font UI 12px, min-height 32px; focus border `--accent-line`. Placeholder: `Message (⌘↵ to commit)`.
|
||||
- **Commit button** (`.commit-btn`): solid `--accent` bg, text `#0c1320`, weight 600, radius 7px, height 32px, padding 0 11px; a check icon + label `Commit` (or `Commit N` when N files are staged). **Disabled** (bg `--bg-3`, text `--fg-3`, `not-allowed`) unless there is ≥1 staged file AND a non-empty message. `⌘↵`/`Ctrl+↵` in the field commits.
|
||||
|
||||
**c. Two stacked lists** (`.git-body`, scrolls). Both lists are **always present** even when empty:
|
||||
- **Staged Changes** group header (`.git-group`) with count; on hover shows an "unstage all" `−` button on the right. Below it, the staged file rows — or, if empty, the hint `Nothing staged — use + to stage a file` (`.git-none`).
|
||||
- A 1px **divider** (`.git-divider`, `--border`, margin 8px 12px 2px).
|
||||
- **Changes** group header with count; hover shows a "stage all" `+` button. Below it, the unstaged rows — or hint `All changes staged`.
|
||||
- If the working tree is fully clean (everything committed), show a centered empty state: a green check icon + `No changes — working tree clean`.
|
||||
|
||||
**Git file row** (`.git-row`, padding 3px 12px 3px 14px, gap 8px, `cursor:pointer`):
|
||||
- **Status letter** (`.git-stat`, mono 11px bold, 13px wide): `M` = `--mod` (amber), `A` = `--add` (green), `D` = `--del` (red), `R` = `--ren` (blue).
|
||||
- **File-type icon** (see Explorer).
|
||||
- **File name** (`--fg-1`; deleted files get `line-through` + `--fg-3`).
|
||||
- **Directory** (`.git-dir`): the parent path, `--fg-3`, 11px, right-aligned with `direction:rtl` ellipsis, max-width ~42%.
|
||||
- **Stage/unstage button** (`.git-act`): appears on row hover, 20×20, a `+` (stage) on Changes rows or `−` (unstage) on Staged rows.
|
||||
- **Delta** (`.git-delta`, mono 10.5px): `+N` in `--add`, `-N` in `--del`.
|
||||
- Hover bg `--hover`; active (open in editor) bg `--sel` + 2px `--accent` left rail.
|
||||
- **Click** opens the file's diff in the editor. **Right-click** → context menu (see Interactions).
|
||||
|
||||
**d. Footer** (`.git-foot`): branch icon + branch name (`--fg-0` bold) on the left; total `+adds`/`-dels` (mono) on the right.
|
||||
|
||||
> Seed/demo data: branch `feat/payments-balance`; 6 changed files; 2 pre-staged (`PaymentService.php` [A], `config/app.json` [M]); 4 unstaged including a deleted `LegacyUser.php` [D].
|
||||
|
||||
### 3. Explorer / file tree (`.col` #2)
|
||||
- Header `.phead`: "EXPLORER" + project name (`console`) right-aligned, mono 10.5px `--fg-3`.
|
||||
- Tree body scrolls. Rows are 23px tall.
|
||||
- **Folder row** (`.tree-row.folder`): a rotating chevron (▸ closed → ▾ open, CSS rotate 90°, .12s) + a simple folder glyph + name (`--fg-1`). Click toggles expand. Indent = `10 + depth*13` px.
|
||||
- **File row**: file-type icon + name. Active file → `--sel` bg + 2px `--accent` left rail. Files with an uncommitted change show a status letter badge (`M`/`A`/`D`) on the right (cleared once that file is committed). Deleted files render struck-through.
|
||||
- **File-type icon** (`.ficon`): a **15×15 rounded-square monogram chip** filled with a per-type color, containing 1–3 dark glyph characters (`#0c0d0f`, mono 7.5px bold). Do **not** use brand logos. Colors/labels:
|
||||
- `.php` → `#a78bdb` "php" · `.js/.mjs` → `#e6c860` "js" · `.ts/.tsx` → `#5a9bd6` "ts" · `.py` → `#5fa8d6` "py" · `.html` → `#e08b6a` "<>" · `.css` → `#5a9bd6` "{}" · `.json` → `#d8a85c` "{}" · `.md` → `#9aa0a8` "md" · `.env/.sh` → `#7fc6a0` "$" · `.yml/.yaml` → `#cf7a6a` "yml".
|
||||
- Special names: `composer.json` `#a78bdb` "co"; `package.json` `#cf7a6a` "pk"; `README.md` `#5a9bd6` "md"; `.env` `#7fc6a0` "$".
|
||||
- Default/unknown: `#7d838c`, first two letters of the name.
|
||||
|
||||
### 4. Editor (`.col` #3)
|
||||
**Tab strip** (`.tabs`, height 35px, bg `--bg-3`, horizontal scroll, no scrollbar):
|
||||
- **Tab** (`.tab`): file-type icon + name, 12.5px, max-width 230px. Inactive `--bg-3`/`--fg-2`; active `--bg-0`/`--fg-0` with a 2px `--accent` top rail. A small mode badge (`.tab-mode`: `orig`/`upd`/`diff`/`split`) shows for changed files. A close `×` (`.tclose`, 17px) on the right; **middle-click** also closes. Hover bg `#272b31`.
|
||||
|
||||
**Diff toolbar** (`.diff-bar`, only shown for changed files; height 26px, bg `--bg-3`):
|
||||
- Left: a status word (`Modified`/`Added`/`Deleted`) tinted by status, then `+N` (green) / `−N` (red).
|
||||
- Right: a **4-segment control** (`.seg`, 1px `--border-2`, radius 7px): **Original · Updated · Diff · [⊟ Split]**. Active segment bg `--accent-soft`, text `--fg-0`. The Split segment carries a small split-pane icon.
|
||||
|
||||
**Code area** (`.editor`, mono 13px, line-height 20px, scrolls). Each line is a flex row (`.ln-row`):
|
||||
- **Gutter** (`.ln-gutter`, 54px, right-aligned, `--fg-3`, clickable to select that line; shift-click extends a range).
|
||||
- (Diff mode only) a **sign column** (`.ln-sign`, 14px): `+`/`-`.
|
||||
- **Code** (`.ln-code`, `white-space:pre`), syntax-highlighted.
|
||||
- Cursor line gets a faint `rgba(255,255,255,.035)` bg; selected range gets `--accent-soft`.
|
||||
|
||||
**The four view modes** (all are presentations of the same per-file diff; same color language everywhere → **red = removed / changed-from, green = added / changed-to**; syntax highlighting stays on in all four):
|
||||
1. **Original** — the pre-edit file, read-only. Removed/changed lines get a **red gutter bar** (`box-shadow: inset 2px 0 0 var(--del)` on the row). No inline +/- markers.
|
||||
2. **Updated** — the current/live file. Added/changed lines get a **green gutter bar** (`inset 2px 0 0 var(--add)`). (In the real app this is the editable buffer.)
|
||||
3. **Diff** — single-pane unified inline diff: removed lines red with `-`, added lines green with `+`, full file context in sequence. This is the default when a changed file is opened.
|
||||
4. **Split** — clicking it opens a **full-screen overlay** (`.split-overlay`, `position:fixed; inset:0; z-index:60`) covering all panels: header bar (file icon + path + status + `+/−`) with a **Collapse** button + `Esc` to exit; body is two side-by-side panes — **Original** left, **Updated** right — with lines aligned (blank padded rows where one side has no counterpart, rendered with a faint diagonal hatch `.ln-row.empty`), red bars on the left, green bars on the right, and **synced vertical scrolling** between panes.
|
||||
|
||||
Empty editor state (`.empty-ed`): file icon + "No file open" + a key-hint list (`Search files & content ⌘F`, `Copy reference right-click`, `Pass on to Agent right-click`).
|
||||
|
||||
### 5. Right column — terminals (`.col` #4)
|
||||
Two stacked terminal panes split by a draggable horizontal splitter (top fraction default 0.52).
|
||||
|
||||
Each pane:
|
||||
- **Header** (`.term-head`, height 28px, bg `--bg-3`): a status dot (the agent pane's dot is green + pulsing when a session is live), a mono label (`claude` / `zsh`), and a right-aligned tag (`agent session` / `— bash · ~/console`).
|
||||
- **Body** (`.term-body`, mono 12.5px, line-height 18px, scrolls, `cursor:text`): a stream of output lines plus an input row at the bottom.
|
||||
- **Input row** (`.term-input`): a colored prompt glyph (agent `>` in violet `#c98bdb`; shell `console %` with `%` in `--fg-3`) + a transparent auto-growing **`<textarea>`** (`.term-ta`, mono 12.5px, `caret-color:--accent`, up to 8 rows). **Enter** submits; **Shift+Enter** inserts a newline; ↑/↓ recall history (only when the buffer has no newline).
|
||||
|
||||
**Agent pane behavior (top):** it is a terminal. Typing `claude` "boots" an agent session (a bordered welcome card + intro line). In a live session, submitting a prompt streams a short simulated response: a "thinking…" line, `read`/`grep` tool lines, an **edit card** (`.term-card` — a bordered block with a header `edit · <path>` and red `-`/green `+` rows), then a green "Done" line. `/exit` ends the session. **In the real app, replace all of this with a real PTY running the `claude` CLI** (node-pty + xterm.js). The simulated content (`agentSeed`, `runAgent`, `bootAgent` in `design/src/terminals.jsx`) is only there to show the intended visual style — keep that styling, drop the fakery.
|
||||
|
||||
**Shell pane (bottom):** a normal terminal. The prototype fakes `ls`, `pwd`, `cat`, `git status`, `git diff`, `echo`, `clear`; replace with a real shell PTY.
|
||||
|
||||
### 6. Status bar (`.statusbar`, height 23px, bg `--bg-3`, 11px)
|
||||
Segments left→right: an **accent-colored** branch chip (bg `--accent`, text `#0c1320`); `+adds / −dels`; spacer; cursor position (`Ln X, Col Y`, or `N lines selected`); `Spaces: 4`; `UTF-8`; active file language (bold); and the current view mode word (`Original`/`Updated`/`Diff`/`Split`) when a changed file is active. Each segment hover bg `--hover`.
|
||||
|
||||
---
|
||||
|
||||
## Interactions & Behavior
|
||||
|
||||
### Search (⌘F) — `SearchModal`
|
||||
- Opened by `⌘F` / `Ctrl+F` or the title-bar Search button. (There is no separate "go to file" command; this one modal does both.)
|
||||
- A scrim (`.scrim`, `rgba(8,9,11,.5)`, slight blur) with a **large centered modal** (`.search-modal`, width 940px, max 94vw).
|
||||
- Top: a single text input (search icon + field, placeholder `Search content and file names…`) and a right-aligned count chip (`N hits · M files`).
|
||||
- Body is **two columns**:
|
||||
- **Left — Content** (`.sc-left`, flex-grow, the wide pane, max-height 460px, scrolls): substring matches across all file contents (case-insensitive, min 2 chars), grouped by file. Each group: a file header (icon + path + hit count); then up to 12 matching lines, each `line# + the line text with the matched term wrapped in <mark>` (`mark` bg `rgba(216,168,92,.28)`). Clicking a line opens that file at that line.
|
||||
- **Right — Files** (`.sc-right`, **fixed 256px — deliberately narrower**, max-height 460px, scrolls, slightly darker bg): fuzzy matches on file name (then path), each a 2-line cell (name with matched chars bolded in `--accent` + dimmed dir path). A `•` dot marks files with uncommitted changes. Clicking opens the file.
|
||||
- Section headers (`.sc-head`) are sticky, uppercase 10px, with a count pill.
|
||||
- **Keyboard:** ↑/↓ move a selection through the flat list of content hits (selected line highlighted `--accent-soft`); **Enter** opens the selected content hit (or the first file if there are no content hits); **Esc** closes. Clicking the scrim closes.
|
||||
|
||||
### Right-click in the editor → context menu (`.ctx`) — exactly two items
|
||||
1. **Copy reference** — copies a **project-relative** reference to the clipboard:
|
||||
- No selection: `path:line` (e.g. `src/Http/Controller/UserController.php:42`).
|
||||
- With a selection: `path:start-end` (e.g. `src/Http/Controller/UserController.php:42-58`).
|
||||
- The clicked line is resolved via `document.caretRangeFromPoint`; a multi-line text selection (or a gutter range-select) yields the start-end form.
|
||||
2. **Pass on to Agent** — opens a small **inline popup** (`.pass-pop`) anchored at the click:
|
||||
- A header (`✦ Pass on to Agent` + `esc` chip), one optional single-line text field (placeholder `Add a note (optional)…`), an `INSERTS` preview showing the composed line, and a footer `↵ insert into agent · esc cancel`.
|
||||
- On **Enter**: compose ONE line = `<typed note> <reference>` (just the bare reference if the field is empty) and insert it into the **agent terminal's input** as a **new, unsubmitted line, leaving the caret on a fresh line below** — so the action can be repeated to **stack several references** before the developer presses Enter to send. **Insert never submits.** In the real app, do this by writing the text to the agent PTY wrapped in **bracketed paste** (`\x1b[200~ … \x1b[201~`).
|
||||
- **Esc** cancels with no insert; clicking outside cancels.
|
||||
- The context menu shows the resolved reference as a muted note at the top.
|
||||
- The menu reuses a generic popup also used by tree/git rows; **only the editor menu is the two-item Copy/Pass menu.** Tree rows offer Copy reference / Send reference to agent / Copy file name / Open / Reveal; Git rows additionally offer **Stage changes** or **Unstage changes** and **Open diff**.
|
||||
|
||||
### Diff mode switching
|
||||
- Clicking Original/Updated/Diff sets the active tab's mode and closes any open Split overlay. Clicking Split opens the full-screen overlay; the previous mode is preserved for when you collapse. `Esc` collapses Split.
|
||||
- Opening a file from the Git panel defaults to **Diff**; opening from search/tree defaults to the plain file (**Updated** for changed files so line numbers map to the current content).
|
||||
|
||||
### Staging / commit
|
||||
- Hover a git row → `+`/`−` to stage/unstage; group headers have stage-all/unstage-all. Right-click → Stage/Unstage.
|
||||
- Commit (button or `⌘↵`) moves staged files out of the working set, clears the message, shows a toast `Committed N file(s)`, and clears those files' badges in the tree.
|
||||
|
||||
### Toasts (`.toast-wrap`, bottom-center)
|
||||
- Small dark pill with an icon, a title, and (optionally) a mono reference chip in `--accent`. Auto-dismiss after ~2.3s. Used for "Copied reference", "Passed to agent", "Committed N files".
|
||||
|
||||
### Keyboard shortcuts
|
||||
- `⌘F` / `Ctrl+F` — open Search.
|
||||
- `⌘W` / `Ctrl+W` — close active tab.
|
||||
- `Esc` — collapse Split if open, else close any overlay/menu.
|
||||
- `⌘↵` in commit box — commit.
|
||||
- In terminals: `Enter` submit, `Shift+Enter` newline, `↑/↓` history.
|
||||
|
||||
### Animations
|
||||
- Chevron rotate .12s. Splitter line color .12s. Overlays/toasts a ~.12s ease-out entrance (translateY 6–8px). Live agent dot: a 2.2s pulsing box-shadow ring. Keep motion subtle; respect `prefers-reduced-motion`.
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
Renderer state (the prototype keeps all of this in the top `App` component; in the real app, lift FS/git/terminal state into the main process and stream via IPC):
|
||||
- `openTabs: string[]` (file paths), `activeTab`, `tabMode: Record<path, 'original'|'updated'|'diff'|'code'>`.
|
||||
- `splitFor: path | null` (which file is in full-screen Split).
|
||||
- `openDirs: Set<path>` (expanded tree folders).
|
||||
- `cursor: {path, line, col}`, `selection: {path, start, end} | null`.
|
||||
- `overlay: 'search' | null`, `menu` (context menu descriptor), `passPopup: {x,y,ref} | null`, `toasts[]`.
|
||||
- Git: `staged: Set<path>`, `committed: Set<path>`, `commitMsg`.
|
||||
- Column sizes: `gitW`, `treeW`, `rightW` (px) + the right column's top-pane fraction.
|
||||
- Per changed file the app needs an **original text** and **updated text**; the diff (`rows` for unified, `left`/`right` for Original/Updated with per-line add/del marks, and `split` aligned pairs, plus `+`/`−` counts) is derived from those. See `buildDiff()` in `design/src/data.js` for the exact derivation (LCS line diff) — in production prefer real `git diff` but keep the same four derived views.
|
||||
|
||||
## Data the real app must supply (replacing `design/src/data.js`)
|
||||
- Project file tree (nested dirs/files) from the opened folder.
|
||||
- File contents on demand.
|
||||
- Git: current branch, changed files with status (`A/M/D/R`) and `+/−` counts, staged/unstaged sets, and original+updated text per changed file.
|
||||
- Search: content matches (ripgrep) and file-name matches (fuzzy).
|
||||
|
||||
---
|
||||
|
||||
## Design Tokens
|
||||
Canonical source: the `:root` block in `design/styles.css`. Key values:
|
||||
|
||||
**Surfaces / neutrals (cool charcoal):**
|
||||
- `--bg-0 #16171a` (editor) · `--bg-1 #1a1c1f` (terminals) · `--bg-2 #1f2226` (sidebars) · `--bg-3 #23262b` (headers/tabs)
|
||||
- `--hover #2a2e34` · `--active #313742` · `--sel #2b323d`
|
||||
- `--border #2a2d33` · `--border-2 #34383f`
|
||||
- Text: `--fg-0 #e6e8ea` · `--fg-1 #b4bac2` · `--fg-2 #838a94` · `--fg-3 #5d636c`
|
||||
|
||||
**Accent (single, cool blue):**
|
||||
- `--accent #4d8dff` · `--accent-soft rgba(77,141,255,.16)` · `--accent-line rgba(77,141,255,.55)`
|
||||
|
||||
**Git / diff status:**
|
||||
- `--add #5cbd6b` · `--del #e0696a` · `--mod #d8a85c` · `--ren #5aa6d6`
|
||||
- `--add-bg rgba(92,189,107,.10)` · `--del-bg rgba(224,105,106,.10)`
|
||||
|
||||
**Syntax tokens (tuned to charcoal):**
|
||||
- keyword `--t-key #c98bdb` · string `--t-str #94c980` · number `--t-num #e0a06a` · function `--t-fn #6aa6f0` · comment `--t-com #5f656e` (italic) · tag `--t-tag #7fc6a0` · attr-name `--t-attr #d8b15c` · punctuation `--t-punc #9aa0a8` · variable `--t-var #e6e8ea` · constant/boolean/builtin `--t-const #e08b6a` · property `--t-prop #6ec0c0`
|
||||
- (See the `.ln-code .token.*` rules in `design/styles.css` for the full Prism token→variable mapping.)
|
||||
|
||||
**Typography:**
|
||||
- UI: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`. Base 13px.
|
||||
- Mono: `"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`. Editor 13px / line-height 20px; terminals 12.5px / 18px.
|
||||
- Panel headers: 10–10.5px uppercase, letter-spacing ~.08em.
|
||||
|
||||
**Radii / spacing:** card/control radii 6–11px (panels 7px, modals 10–11px, chips 4–5px). Icon chips 15px / radius 3.5px. Splitters 5px. Column gaps are the splitters; inside panels use the paddings noted per component.
|
||||
|
||||
**Shadows:** menus/popovers `0 16px 44px rgba(0,0,0,.5)`; modals `0 24px 70px rgba(0,0,0,.55)`; toasts `0 12px 34px rgba(0,0,0,.45)`.
|
||||
|
||||
## Assets
|
||||
- **No raster/brand assets.** All icons are simple inline SVGs (geometric: search, branch, close, copy, terminal, spark/diamond, file, folder, diff, plus, minus, check, discard, expand/collapse) — see the `Icon` map in `design/src/components.jsx`. File-type icons are colored monogram chips (no third-party logos). Recreate these as a small icon set (or substitute an open icon library like Lucide for the UI icons, keeping the monogram chips for file types).
|
||||
- **Font:** JetBrains Mono (OFL) — bundle locally.
|
||||
|
||||
## Files in this bundle
|
||||
- `design/Helder - AI Code Workbench.html` — entry point; open in a browser to view the live prototype.
|
||||
- `design/styles.css` — all styling + the canonical design-token `:root` block.
|
||||
- `design/src/data.js` — mock filesystem, git changes, before/after file pairs, and the `buildDiff()` LCS diff that powers the four view modes. **Replace with real FS/git data.**
|
||||
- `design/src/highlight.js` — Prism language/ext mapping + monogram file-icon metadata.
|
||||
- `design/src/components.jsx` — `Icon` set, `FileIcon`, `GitPanel`/`GitRow`, `FileTree`.
|
||||
- `design/src/editor.jsx` — tabs, the four-mode editor (`PaneView`, `buildLines`), and the full-screen `SplitView`.
|
||||
- `design/src/terminals.jsx` — the agent + shell terminal component and the simulated session (replace the simulation with real PTYs).
|
||||
- `design/src/overlays.jsx` — `SearchModal`, `ContextMenu`, `PassPopup`, `Toasts`.
|
||||
- `design/src/app.jsx` — composition: layout, resizable splitters, keyboard shortcuts, git/commit/stage logic, copy-reference + pass-to-agent wiring.
|
||||
|
||||
## Implementation order (suggested)
|
||||
1. Electron shell + frameless dark window; port tokens to CSS variables; bundle JetBrains Mono.
|
||||
2. Static layout: four resizable columns + title/status bars.
|
||||
3. Real file tree + open files into tabs (read-only) with Prism highlighting.
|
||||
4. Git panel from `git status` (read-only), then staging + commit, then the four diff modes + Split.
|
||||
5. Search (ripgrep + fuzzy).
|
||||
6. Terminals via node-pty + xterm.js; run `claude` in the agent pane.
|
||||
7. Copy reference + Pass-on-to-Agent (clipboard + bracketed-paste into the agent PTY).
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Helder — AI Code Workbench</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
|
||||
<!-- Prism (manual highlighting) -->
|
||||
<script>window.Prism = window.Prism || {}; window.Prism.manual = true;</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup-templating.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-php.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
|
||||
|
||||
<!-- React + Babel (pinned) -->
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- data + helpers (plain JS) -->
|
||||
<script src="src/data.js"></script>
|
||||
<script src="src/highlight.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- components (JSX) -->
|
||||
<script type="text/babel" src="src/components.jsx"></script>
|
||||
<script type="text/babel" src="src/editor.jsx"></script>
|
||||
<script type="text/babel" src="src/terminals.jsx"></script>
|
||||
<script type="text/babel" src="src/overlays.jsx"></script>
|
||||
<script type="text/babel" src="src/app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
296
design_handoff_helder_workbench/design/src/app.jsx
Normal file
296
design_handoff_helder_workbench/design/src/app.jsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference, status bar */
|
||||
|
||||
function Splitter({ orientation = "v", onDelta }) {
|
||||
const [drag, setDrag] = useState(false);
|
||||
function down(e) {
|
||||
e.preventDefault();
|
||||
let last = { x: e.clientX, y: e.clientY };
|
||||
setDrag(true);
|
||||
document.body.style.cursor = orientation === "v" ? "col-resize" : "row-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
function mv(ev) {
|
||||
onDelta(ev.clientX - last.x, ev.clientY - last.y);
|
||||
last = { x: ev.clientX, y: ev.clientY };
|
||||
}
|
||||
function up() {
|
||||
setDrag(false);
|
||||
document.body.style.cursor = ""; document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up);
|
||||
}
|
||||
document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up);
|
||||
}
|
||||
return <div className={"splitter" + (orientation === "h" ? " h" : "") + (drag ? " drag" : "")} onMouseDown={down} />;
|
||||
}
|
||||
|
||||
function RightColumn({ width }) {
|
||||
const [topFrac, setTopFrac] = useState(0.52);
|
||||
const ref = useRef(null);
|
||||
function delta(dx, dy) {
|
||||
const h = ref.current ? ref.current.clientHeight : 600;
|
||||
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)));
|
||||
}
|
||||
const agent = useMemo(() => agentSeed(), []);
|
||||
const shell = useMemo(() => shellSeed(), []);
|
||||
return (
|
||||
<div className="col right-col" style={{ width, flex: "0 0 " + width + "px" }}>
|
||||
<div ref={ref} style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ flex: "0 0 " + (topFrac * 100) + "%", minHeight: 0, display: "flex" }}>
|
||||
<Terminal kind="agent" seed={agent} />
|
||||
</div>
|
||||
<Splitter orientation="h" onDelta={delta} />
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||
<Terminal kind="shell" seed={shell} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function ancestors(path) {
|
||||
const parts = path.split("/"); const out = [];
|
||||
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("/"));
|
||||
return out;
|
||||
}
|
||||
function initialOpenDirs(node, set) {
|
||||
if (node.type === "dir") {
|
||||
if (node.open && node.path) set.add(node.path);
|
||||
(node.children || []).forEach((c) => initialOpenDirs(c, set));
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const changeMap = useMemo(() => Object.fromEntries(PROJECT.changes.map((c) => [c.path, c.status])), []);
|
||||
const changeSet = useMemo(() => new Set(PROJECT.changes.map((c) => c.path)), []);
|
||||
|
||||
const [tabs, setTabs] = useState([
|
||||
{ path: "src/Http/Controller/UserController.php" },
|
||||
{ path: "public/assets/app.js" },
|
||||
{ path: "src/types/api.ts" },
|
||||
]);
|
||||
const [active, setActive] = useState("src/Http/Controller/UserController.php");
|
||||
const [tabMode, setTabMode] = useState({ "src/Http/Controller/UserController.php": "diff" });
|
||||
const [openDirs, setOpenDirs] = useState(() => initialOpenDirs(PROJECT.tree, new Set()));
|
||||
const [cursor, setCursor] = useState({ path: "src/Http/Controller/UserController.php", line: 1, col: 1 });
|
||||
const [selection, setSelection] = useState(null);
|
||||
const [overlay, setOverlay] = useState(null);
|
||||
const [menu, setMenu] = useState(null);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [splitFor, setSplitFor] = useState(null);
|
||||
const [staged, setStaged] = useState(() => new Set(["src/Service/PaymentService.php", "config/app.json"]));
|
||||
const [committed, setCommitted] = useState(() => new Set());
|
||||
const [commitMsg, setCommitMsg] = useState("");
|
||||
const [passPopup, setPassPopup] = useState(null);
|
||||
|
||||
const [gitW, setGitW] = useState(232);
|
||||
const [treeW, setTreeW] = useState(244);
|
||||
const [rightW, setRightW] = useState(444);
|
||||
|
||||
function toast(title, ref) {
|
||||
const id = lid();
|
||||
setToasts((t) => [...t, { id, title, ref }]);
|
||||
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2300);
|
||||
}
|
||||
async function copyText(text, label) {
|
||||
try { await navigator.clipboard.writeText(text); }
|
||||
catch (e) {
|
||||
const ta = document.createElement("textarea"); ta.value = text;
|
||||
ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta);
|
||||
ta.select(); try { document.execCommand("copy"); } catch (_) {} ta.remove();
|
||||
}
|
||||
toast(label || "Copied reference", text);
|
||||
}
|
||||
|
||||
const toggleDir = useCallback((p) => {
|
||||
setOpenDirs((s) => { const n = new Set(s); n.has(p) ? n.delete(p) : n.add(p); return n; });
|
||||
}, []);
|
||||
|
||||
function reveal(path) {
|
||||
setOpenDirs((s) => { const n = new Set(s); ancestors(path).forEach((a) => n.add(a)); return n; });
|
||||
}
|
||||
|
||||
const stage = (p) => setStaged((s) => { const n = new Set(s); n.add(p); return n; });
|
||||
const unstage = (p) => setStaged((s) => { const n = new Set(s); n.delete(p); return n; });
|
||||
const stageAll = () => setStaged(new Set(PROJECT.changes.filter((c) => !committed.has(c.path)).map((c) => c.path)));
|
||||
const unstageAll = () => setStaged(new Set());
|
||||
function commit() {
|
||||
const list = PROJECT.changes.filter((c) => staged.has(c.path) && !committed.has(c.path));
|
||||
if (!list.length || !commitMsg.trim()) return;
|
||||
setCommitted((prev) => { const n = new Set(prev); list.forEach((c) => n.add(c.path)); return n; });
|
||||
setStaged(new Set());
|
||||
const msg = commitMsg.trim();
|
||||
setCommitMsg("");
|
||||
toast(`Committed ${list.length} file${list.length > 1 ? "s" : ""}`, msg.length > 34 ? msg.slice(0, 34) + "…" : msg);
|
||||
}
|
||||
|
||||
function openFile(path, opts = {}) {
|
||||
const changed = !!PROJECT.diffs[path];
|
||||
setTabs((t) => t.some((x) => x.path === path) ? t : [...t, { path }]);
|
||||
setActive(path);
|
||||
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? "diff" : (m[path] || (changed ? "diff" : "code")) }));
|
||||
reveal(path);
|
||||
if (opts.line) {
|
||||
// show the current/updated file so line numbers map to search hits
|
||||
setSplitFor(null);
|
||||
setTabMode((m) => ({ ...m, [path]: changed ? "updated" : "code" }));
|
||||
setCursor({ path, line: opts.line, col: 1 });
|
||||
setSelection(null);
|
||||
setTimeout(() => {
|
||||
const row = document.querySelector('.editor .ln-row[data-line="' + opts.line + '"]');
|
||||
if (row) { const ed = row.closest(".editor"); const er = ed.getBoundingClientRect(), rr = row.getBoundingClientRect(); ed.scrollTop += (rr.top - er.top) - ed.clientHeight / 2; }
|
||||
}, 70);
|
||||
}
|
||||
}
|
||||
|
||||
function closeTab(path) {
|
||||
setTabs((t) => {
|
||||
const ix = t.findIndex((x) => x.path === path);
|
||||
const next = t.filter((x) => x.path !== path);
|
||||
if (path === active) {
|
||||
const fallback = next[ix] || next[ix - 1] || next[next.length - 1];
|
||||
setActive(fallback ? fallback.path : null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- context menus ----
|
||||
function openMenu(e, target) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const sparkSend = (ref) => ({ icon: Icon.spark({}), label: "Send reference to agent", onClick: () => { window.dispatchEvent(new CustomEvent("agentPaste", { detail: ref })); toast("Passed to agent", ref); } });
|
||||
if (target.kind === "editor") {
|
||||
const ref = target.sel ? `${target.path}:${target.sel.start}-${target.sel.end}` : `${target.path}:${target.line}`;
|
||||
const mx = e.clientX, my = e.clientY;
|
||||
setMenu({
|
||||
x: mx, y: my, note: ref,
|
||||
items: [
|
||||
{ primary: true, icon: Icon.copy({}), label: "Copy reference", onClick: () => copyText(ref) },
|
||||
{ icon: Icon.spark({}), label: "Pass on to Agent", onClick: () => setPassPopup({ x: mx, y: my, ref }) },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
const isDir = target.kind === "dir";
|
||||
const ref = isDir ? target.path + "/" : target.path;
|
||||
const name = target.path.split("/").pop();
|
||||
const items = [
|
||||
{ primary: true, icon: Icon.copy({}), label: "Copy reference", onClick: () => copyText(ref) },
|
||||
sparkSend(ref),
|
||||
{ icon: Icon.copy({}), label: isDir ? "Copy folder path" : "Copy file name", onClick: () => copyText(isDir ? target.path : name, "Copied") },
|
||||
];
|
||||
if (!isDir) {
|
||||
items.push({ sep: true });
|
||||
if (target.kind === "git") {
|
||||
const isStaged = staged.has(target.path);
|
||||
items.push(isStaged
|
||||
? { icon: Icon.minus({}), label: "Unstage changes", onClick: () => unstage(target.path) }
|
||||
: { icon: Icon.plus({}), label: "Stage changes", onClick: () => stage(target.path) });
|
||||
items.push({ icon: Icon.diff({}), label: "Open diff", onClick: () => openFile(target.path, { diff: true }) });
|
||||
}
|
||||
items.push({ icon: Icon.file({}), label: "Open file", onClick: () => openFile(target.path) });
|
||||
items.push({ icon: Icon.reveal({}), label: "Reveal in Explorer", onClick: () => reveal(target.path) });
|
||||
}
|
||||
setMenu({ x: e.clientX, y: e.clientY, note: ref, items });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shortcuts ----
|
||||
useEffect(() => {
|
||||
function onKey(e) {
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
if (meta && e.key.toLowerCase() === "f") { e.preventDefault(); setOverlay("search"); }
|
||||
else if (meta && e.key.toLowerCase() === "w") { e.preventDefault(); if (active) closeTab(active); }
|
||||
else if (e.key === "Escape") { if (splitFor) setSplitFor(null); else { setOverlay(null); setMenu(null); } }
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [active, splitFor]);
|
||||
|
||||
const MODE_LABEL = { original: "orig", updated: "upd", diff: "diff", code: "" };
|
||||
const MODE_WORD = { original: "Original", updated: "Updated", diff: "Diff" };
|
||||
const resolvedTabs = tabs.map((t) => {
|
||||
const changed = !!PROJECT.diffs[t.path];
|
||||
const m = tabMode[t.path] || (changed ? "diff" : "code");
|
||||
return { ...t, changed, modeLabel: splitFor === t.path ? "split" : MODE_LABEL[m] };
|
||||
});
|
||||
const mode = tabMode[active] || (PROJECT.diffs[active] ? "diff" : "code");
|
||||
const totals = PROJECT.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 });
|
||||
const activeLang = active ? HL.langLabel(active) : "";
|
||||
|
||||
const crumb = active ? active.split("/") : [];
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* title bar */}
|
||||
<div className="titlebar">
|
||||
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
|
||||
<div className="tb-title">{Icon.spark({ style: { color: "var(--accent)" } })}<b>Helder</b><span style={{ color: "var(--fg-3)" }}>—</span><span style={{ color: "var(--fg-2)" }}>{PROJECT.name}</span></div>
|
||||
{active && (
|
||||
<div className="tb-crumb">
|
||||
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"> › </span>}<span style={i === crumb.length - 1 ? { color: "var(--fg-1)" } : null}>{s}</span></React.Fragment>))}
|
||||
</div>
|
||||
)}
|
||||
<div className="tb-spacer" />
|
||||
<div className="tb-actions">
|
||||
<button className="tb-btn" onClick={() => setOverlay("search")}>{Icon.search({})} Search <kbd>⌘F</kbd></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* workbench */}
|
||||
<div className="workbench">
|
||||
<div className="col" style={{ width: gitW, flex: "0 0 " + gitW + "px" }}>
|
||||
<GitPanel changes={PROJECT.changes} staged={staged} committed={committed}
|
||||
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
||||
onStage={stage} onUnstage={unstage} onStageAll={stageAll} onUnstageAll={unstageAll} onCommit={commit}
|
||||
onOpen={openFile} onContext={openMenu} activePath={active} />
|
||||
</div>
|
||||
<Splitter onDelta={(dx) => setGitW((w) => clamp(w + dx, 160, 460))} />
|
||||
|
||||
<div className="col" style={{ width: treeW, flex: "0 0 " + treeW + "px" }}>
|
||||
<FileTree tree={PROJECT.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
|
||||
onContext={openMenu} activePath={active} changeMap={changeMap} committed={committed} />
|
||||
</div>
|
||||
<Splitter onDelta={(dx) => setTreeW((w) => clamp(w + dx, 160, 520))} />
|
||||
|
||||
<div className="col editor-col">
|
||||
<Editor tabs={resolvedTabs} active={active} mode={mode}
|
||||
setMode={(m) => { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); }}
|
||||
onActivate={setActive} onClose={closeTab} onContext={openMenu}
|
||||
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
|
||||
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection} />
|
||||
</div>
|
||||
<Splitter onDelta={(dx) => setRightW((w) => clamp(w - dx, 280, 780))} />
|
||||
|
||||
<RightColumn width={rightW} />
|
||||
</div>
|
||||
|
||||
{/* status bar */}
|
||||
<div className="statusbar">
|
||||
<div className="sb accent">{Icon.branch({ width: 12, height: 12 })}<span style={{ color: "#0c1320" }}>{PROJECT.branch}</span></div>
|
||||
<div className="sb"><span className="a">+{totals.add}</span> <span className="d">−{totals.del}</span></div>
|
||||
<div className="sb spacer" />
|
||||
{active && <div className="sb">{selection && selection.path === active && selection.start !== selection.end ? `${selection.end - selection.start + 1} lines selected` : `Ln ${cursor.path === active ? cursor.line : 1}, Col ${cursor.path === active ? cursor.col : 1}`}</div>}
|
||||
{active && <div className="sb">Spaces: 4</div>}
|
||||
{active && <div className="sb">UTF-8</div>}
|
||||
{active && <div className="sb"><b>{activeLang}</b></div>}
|
||||
{active && PROJECT.diffs[active] && <div className="sb">{splitFor === active ? "Split" : (MODE_WORD[mode] || "")}</div>}
|
||||
</div>
|
||||
|
||||
{/* overlays */}
|
||||
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />}
|
||||
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref}
|
||||
onConfirm={(text) => {
|
||||
const line = (text && text.trim() ? text.trim() + " " : "") + passPopup.ref;
|
||||
window.dispatchEvent(new CustomEvent("agentPaste", { detail: line }));
|
||||
setPassPopup(null);
|
||||
toast("Passed to agent", passPopup.ref);
|
||||
}}
|
||||
onCancel={() => setPassPopup(null)} />}
|
||||
{overlay === "search" && <SearchModal onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
||||
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
||||
<Toasts toasts={toasts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||||
186
design_handoff_helder_workbench/design/src/components.jsx
Normal file
186
design_handoff_helder_workbench/design/src/components.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/* Shared icons, FileIcon, GitPanel, FileTree */
|
||||
const { useState, useEffect, useRef, useMemo, useCallback } = React;
|
||||
|
||||
/* ---- minimal geometric icons ---- */
|
||||
const Icon = {
|
||||
search: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.4"/><line x1="10.5" y1="10.5" x2="14" y2="14" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>),
|
||||
branch: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><circle cx="4" cy="3.5" r="1.8" stroke="currentColor" strokeWidth="1.3"/><circle cx="4" cy="12.5" r="1.8" stroke="currentColor" strokeWidth="1.3"/><circle cx="12" cy="5" r="1.8" stroke="currentColor" strokeWidth="1.3"/><path d="M4 5.3v5.4M5.8 5C9 5 10 6.2 10 9v0" stroke="currentColor" strokeWidth="1.3" fill="none"/></svg>),
|
||||
close: (p) => (<svg width="11" height="11" viewBox="0 0 12 12" fill="none" {...p}><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>),
|
||||
copy: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="5" y="5" width="8" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.3"/><path d="M3 11V3a1 1 0 0 1 1-1h6" stroke="currentColor" strokeWidth="1.3" fill="none"/></svg>),
|
||||
terminal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4l3 3-3 3M8 11h5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
||||
spark: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 1.5l1.6 4.9L14.5 8l-4.9 1.6L8 14.5 6.4 9.6 1.5 8l4.9-1.6L8 1.5z" stroke="currentColor" strokeWidth="1.1" fill="none" strokeLinejoin="round"/></svg>),
|
||||
file: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" strokeWidth="1.2" fill="none"/><path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.2" fill="none"/></svg>),
|
||||
reveal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M2 4.5h4l1.3 1.5H14V13H2V4.5z" stroke="currentColor" strokeWidth="1.2" fill="none"/></svg>),
|
||||
diff: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2v8M4 12.5v1.5M2 4h4M2 8h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/><path d="M12 14V6M12 3.5V2M10 12h4M10 8h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>),
|
||||
plus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M7 2.5v9M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>),
|
||||
minus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>),
|
||||
check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
||||
discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round"/><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
||||
};
|
||||
|
||||
const Chevron = ({ open }) => (
|
||||
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: open ? "rotate(90deg)" : "none", transition: "transform .12s" }}>
|
||||
<path d="M3.5 2l3.5 3-3.5 3" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FolderIcon = ({ open }) => (
|
||||
<svg className="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d={open ? "M1.5 4.5h4l1.2 1.4H14V13H2V4.5z" : "M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z"}
|
||||
fill={open ? "rgba(122,131,140,.18)" : "rgba(122,131,140,.12)"} stroke="currentColor" strokeWidth="1.1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function FileIcon({ path }) {
|
||||
const ic = HL.iconFor(path);
|
||||
return <span className="ficon" style={{ background: ic.c }}><span>{ic.t}</span></span>;
|
||||
}
|
||||
|
||||
/* ============ Git / Source Control panel ============ */
|
||||
function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }) {
|
||||
const name = c.path.split("/").pop();
|
||||
const dir = c.path.split("/").slice(0, -1).join("/");
|
||||
return (
|
||||
<div className={"git-row" + (activePath === c.path ? " active" : "")}
|
||||
onClick={() => onOpen(c.path, { diff: true })}
|
||||
onContextMenu={(e) => onContext(e, { path: c.path, kind: "git", staged })}
|
||||
title={c.path}>
|
||||
<span className={"git-stat " + c.status}>{c.status}</span>
|
||||
<FileIcon path={c.path} />
|
||||
<span className={"git-name" + (c.deleted ? " del" : "")}>{name}</span>
|
||||
{dir && <span className="git-dir">{dir}/</span>}
|
||||
<button className="git-act" title={staged ? "Unstage changes" : "Stage changes"}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path); }}>
|
||||
{staged ? Icon.minus({}) : Icon.plus({})}
|
||||
</button>
|
||||
<span className="git-delta">
|
||||
{c.add > 0 && <span className="a">+{c.add}</span>}
|
||||
{c.del > 0 && <span className="d">-{c.del}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GitPanel({ changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath }) {
|
||||
const visible = changes.filter((c) => !committed.has(c.path));
|
||||
const stagedList = visible.filter((c) => staged.has(c.path));
|
||||
const changesList = visible.filter((c) => !staged.has(c.path));
|
||||
const totals = visible.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 });
|
||||
const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="phead">
|
||||
{Icon.branch({})}<span>Source Control</span>
|
||||
<span className="ct">{visible.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="commit-box">
|
||||
<textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
|
||||
placeholder="Message (⌘↵ to commit)"
|
||||
onChange={(e) => setCommitMsg(e.target.value)}
|
||||
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && canCommit) { e.preventDefault(); onCommit(); } }} />
|
||||
<button className="commit-btn" disabled={!canCommit} onClick={onCommit}
|
||||
title={canCommit ? "Commit staged changes" : "Stage files and write a message to commit"}>
|
||||
{Icon.check({})}<span>Commit{stagedList.length ? " " + stagedList.length : ""}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="git-body">
|
||||
{visible.length === 0 ? (
|
||||
<div className="git-empty">{Icon.check({ width: 20, height: 20 })}<span>No changes — working tree clean</span></div>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<div className="git-group">
|
||||
Staged Changes <span className="gc">{stagedList.length}</span>
|
||||
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus({})}</button>}
|
||||
</div>
|
||||
{stagedList.length > 0 ? stagedList.map((c) => (
|
||||
<GitRow key={c.path} c={c} staged={true} activePath={activePath}
|
||||
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
|
||||
)) : (
|
||||
<div className="git-none">Nothing staged — use <span className="key">+</span> to stage a file</div>
|
||||
)}
|
||||
|
||||
<div className="git-divider" />
|
||||
|
||||
<div className="git-group">
|
||||
Changes <span className="gc">{changesList.length}</span>
|
||||
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus({})}</button>}
|
||||
</div>
|
||||
{changesList.length > 0 ? changesList.map((c) => (
|
||||
<GitRow key={c.path} c={c} staged={false} activePath={activePath}
|
||||
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
|
||||
)) : (
|
||||
<div className="git-none">All changes staged</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="git-foot">
|
||||
<span className="branch-chip">{Icon.branch({})}<b>{PROJECT.branch}</b></span>
|
||||
<span style={{ marginLeft: "auto", fontFamily: "var(--mono)" }}>
|
||||
<span className="a" style={{ color: "var(--add)" }}>+{totals.add}</span>{" "}
|
||||
<span className="d" style={{ color: "var(--del)" }}>-{totals.del}</span>
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ File Tree ============ */
|
||||
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }) {
|
||||
const pad = 10 + depth * 13;
|
||||
if (node.type === "dir") {
|
||||
const isOpen = openDirs.has(node.path) || node.path === "";
|
||||
return (
|
||||
<React.Fragment>
|
||||
{node.path !== "" && (
|
||||
<div className="tree-row folder" style={{ paddingLeft: pad }}
|
||||
onClick={() => toggleDir(node.path)}
|
||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: "dir" })}>
|
||||
<span className="tw"><Chevron open={isOpen} /></span>
|
||||
<FolderIcon open={isOpen} />
|
||||
<span className="tree-label">{node.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && node.children.map((c) => (
|
||||
<TreeNode key={c.path} node={c} depth={node.path === "" ? 0 : depth + 1}
|
||||
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
|
||||
onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
const status = committed && committed.has(node.path) ? null : changeMap[node.path];
|
||||
return (
|
||||
<div className={"tree-row" + (activePath === node.path ? " active" : "")}
|
||||
style={{ paddingLeft: pad + 2 }}
|
||||
onClick={() => onOpen(node.path)}
|
||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: "file" })}
|
||||
title={node.path}>
|
||||
<span className="tw" />
|
||||
<FileIcon path={node.path} />
|
||||
<span className="tree-label" style={status === "D" ? { textDecoration: "line-through", color: "var(--fg-3)" } : null}>{node.name}</span>
|
||||
{status && <span className={"tree-badge " + status}>{status}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="phead">
|
||||
<span>Explorer</span>
|
||||
<span style={{ marginLeft: "auto", color: "var(--fg-3)", textTransform: "none", letterSpacing: 0, fontFamily: "var(--mono)", fontSize: 10.5 }}>{tree.name}</span>
|
||||
</div>
|
||||
<div className="tree-body">
|
||||
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
|
||||
onOpen={onOpen} onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Icon, Chevron, FolderIcon, FileIcon, GitPanel, FileTree });
|
||||
712
design_handoff_helder_workbench/design/src/data.js
Normal file
712
design_handoff_helder_workbench/design/src/data.js
Normal file
@@ -0,0 +1,712 @@
|
||||
/* Mock project: filesystem tree, file contents, before/after pairs, runtime diff. */
|
||||
(function () {
|
||||
// ---- working-tree (current / updated) file contents ----------------
|
||||
const F = {};
|
||||
|
||||
F["src/Http/Controller/UserController.php"] = `<?php
|
||||
|
||||
namespace App\\Http\\Controller;
|
||||
|
||||
use App\\Service\\PaymentService;
|
||||
use App\\Repository\\UserRepository;
|
||||
use Psr\\Http\\Message\\ResponseInterface;
|
||||
use Psr\\Http\\Message\\ServerRequestInterface;
|
||||
|
||||
final class UserController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $users,
|
||||
private readonly PaymentService $payments,
|
||||
) {}
|
||||
|
||||
public function show(ServerRequestInterface $request, string $id): ResponseInterface
|
||||
{
|
||||
$user = $this->users->find($id);
|
||||
|
||||
if ($user === null) {
|
||||
return $this->json(['error' => 'user_not_found'], 404);
|
||||
}
|
||||
|
||||
$balance = $this->payments->balanceFor($user);
|
||||
|
||||
return $this->json([
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'plan' => $user->plan->value,
|
||||
'name' => $user->name,
|
||||
'currency' => $user->currency,
|
||||
'balance' => $balance->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, string $id): ResponseInterface
|
||||
{
|
||||
$user = $this->users->find($id);
|
||||
if ($user === null) {
|
||||
return $this->json(['error' => 'user_not_found'], 404);
|
||||
}
|
||||
|
||||
$data = (array) $request->getParsedBody();
|
||||
$user->fill($this->onlyFillable($data));
|
||||
$this->users->save($user);
|
||||
|
||||
return $this->json($user->toArray());
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function onlyFillable(array $data): array
|
||||
{
|
||||
$allowed = ['email', 'plan', 'name'];
|
||||
return array_intersect_key($data, array_flip($allowed));
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
F["src/Service/PaymentService.php"] = `<?php
|
||||
|
||||
namespace App\\Service;
|
||||
|
||||
use App\\Entity\\User;
|
||||
use App\\ValueObject\\Money;
|
||||
use App\\Gateway\\PaymentGateway;
|
||||
use Psr\\Log\\LoggerInterface;
|
||||
|
||||
final class PaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaymentGateway $gateway,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function balanceFor(User $user): Money
|
||||
{
|
||||
$cents = $this->gateway->lookupBalance($user->id);
|
||||
return Money::fromCents($cents, $user->currency ?? 'EUR');
|
||||
}
|
||||
|
||||
public function charge(User $user, Money $amount, string $reason): bool
|
||||
{
|
||||
if ($amount->isZero()) {
|
||||
$this->logger->warning('Skipped zero charge', ['user' => $user->id]);
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->gateway->charge($user->paymentToken, $amount->cents());
|
||||
$this->logger->info('Charge attempt', [
|
||||
'user' => $user->id,
|
||||
'amount' => $amount->cents(),
|
||||
'ok' => $result->success,
|
||||
]);
|
||||
|
||||
return $result->success;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
F["public/assets/app.js"] = `import { createStore } from './store.js';
|
||||
import { mountRouter } from './router.js';
|
||||
|
||||
const store = createStore({
|
||||
user: null,
|
||||
notifications: [],
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const res = await fetch('/api/session', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const session = await res.json();
|
||||
store.set('user', session.user);
|
||||
store.set('theme', session.user.theme ?? 'dark');
|
||||
}
|
||||
|
||||
mountRouter(document.querySelector('#app'), store);
|
||||
store.subscribe('notifications', renderToasts);
|
||||
}
|
||||
|
||||
function renderToasts(list) {
|
||||
const host = document.querySelector('#toasts');
|
||||
host.replaceChildren(...list.map((n) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = \\\`toast toast--\\\${n.level}\\\`;
|
||||
el.textContent = n.message;
|
||||
return el;
|
||||
}));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', bootstrap);
|
||||
`;
|
||||
|
||||
F["public/assets/store.js"] = `export function createStore(initial = {}) {
|
||||
let state = { ...initial };
|
||||
const subs = new Map();
|
||||
|
||||
return {
|
||||
get: (key) => state[key],
|
||||
set(key, value) {
|
||||
state = { ...state, [key]: value };
|
||||
(subs.get(key) || []).forEach((fn) => fn(value, state));
|
||||
},
|
||||
subscribe(key, fn) {
|
||||
const list = subs.get(key) || [];
|
||||
list.push(fn);
|
||||
subs.set(key, list);
|
||||
return () => subs.set(key, list.filter((f) => f !== fn));
|
||||
},
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
F["public/assets/styles.css"] = `:root {
|
||||
--brand: #4d8dff;
|
||||
--ink: #15171a;
|
||||
--paper: #ffffff;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: var(--ink);
|
||||
color: #e6e8ea;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
border-left: 3px solid var(--brand);
|
||||
}
|
||||
|
||||
.toast--error { border-left-color: #e0696a; }
|
||||
.toast--success { border-left-color: #5cbd6b; }
|
||||
`;
|
||||
|
||||
F["src/types/api.ts"] = `export type Plan = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
plan: Plan;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
cents: number;
|
||||
currency: string;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export type ApiResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: string; status: number };
|
||||
|
||||
export async function getUser(id: string): Promise<ApiResult<User>> {
|
||||
const res = await fetch(\\\`/api/users/\\\${id}\\\`);
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: 'request_failed', status: res.status };
|
||||
}
|
||||
return { ok: true, data: (await res.json()) as User };
|
||||
}
|
||||
`;
|
||||
|
||||
F["scripts/migrate.py"] = `#!/usr/bin/env python3
|
||||
"""Run pending database migrations in order."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from db import connect, applied_migrations
|
||||
|
||||
MIGRATIONS = Path(__file__).parent / "migrations"
|
||||
|
||||
|
||||
def pending(conn):
|
||||
done = applied_migrations(conn)
|
||||
files = sorted(MIGRATIONS.glob("*.sql"))
|
||||
return [f for f in files if f.stem not in done]
|
||||
|
||||
|
||||
def run(conn, migration: Path) -> None:
|
||||
sql = migration.read_text()
|
||||
print(f" -> applying {migration.stem}")
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
cur.execute(
|
||||
"INSERT INTO schema_migrations (version) VALUES (%s)",
|
||||
(migration.stem,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
conn = connect()
|
||||
todo = pending(conn)
|
||||
if not todo:
|
||||
print("Database is up to date.")
|
||||
return 0
|
||||
print(f"Applying {len(todo)} migration(s)...")
|
||||
for migration in todo:
|
||||
run(conn, migration)
|
||||
print("Done.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
`;
|
||||
|
||||
F["scripts/seed.py"] = `#!/usr/bin/env python3
|
||||
"""Seed the database with demo data for local development."""
|
||||
import random
|
||||
from db import connect
|
||||
|
||||
PLANS = ["free", "pro", "enterprise"]
|
||||
|
||||
|
||||
def seed_users(conn, count: int = 25) -> None:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(count):
|
||||
cur.execute(
|
||||
"INSERT INTO users (email, plan) VALUES (%s, %s)",
|
||||
(f"user{i}@example.com", random.choice(PLANS)),
|
||||
)
|
||||
conn.commit()
|
||||
print(f"Seeded {count} users.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_users(connect())
|
||||
`;
|
||||
|
||||
F["templates/dashboard.html"] = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Dashboard</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="layout">
|
||||
<header class="topbar">
|
||||
<h1 class="logo">Console</h1>
|
||||
<nav class="nav">
|
||||
<a href="/users" class="nav__link">Users</a>
|
||||
<a href="/billing" class="nav__link">Billing</a>
|
||||
</nav>
|
||||
</header>
|
||||
<section id="content" class="content"></section>
|
||||
</main>
|
||||
<div id="toasts" class="toast-host"></div>
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
F["config/app.json"] = `{
|
||||
"name": "console",
|
||||
"env": "production",
|
||||
"features": {
|
||||
"billing": true,
|
||||
"newDashboard": true,
|
||||
"exportCsv": false
|
||||
},
|
||||
"payment": {
|
||||
"gateway": "stripe",
|
||||
"currency": "EUR",
|
||||
"retryLimit": 3
|
||||
},
|
||||
"logging": {
|
||||
"level": "info",
|
||||
"channel": "stdout"
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
F["composer.json"] = `{
|
||||
"name": "blijnder/console",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/log": "^3.0",
|
||||
"psr/http-message": "^2.0",
|
||||
"nyholm/psr7": "^1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"phpstan/phpstan": "^1.11"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": { "App\\\\": "src/" }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
F["package.json"] = `{
|
||||
"name": "console-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.3.0",
|
||||
"vitest": "^2.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
F["README.md"] = `# Console
|
||||
|
||||
Internal admin console. PHP API + small vanilla JS frontend.
|
||||
|
||||
## Getting started
|
||||
|
||||
composer install
|
||||
npm install
|
||||
python scripts/migrate.py
|
||||
npm run dev
|
||||
|
||||
## Layout
|
||||
|
||||
- \`src/\` PHP application code (PSR-4, \`App\\\` namespace)
|
||||
- \`public/\` Document root and frontend assets
|
||||
- \`scripts/\` Python maintenance + migration scripts
|
||||
- \`templates/\` Server-rendered HTML
|
||||
`;
|
||||
|
||||
F[".env"] = `APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
DATABASE_URL=postgres://localhost:5432/console
|
||||
PAYMENT_GATEWAY=stripe
|
||||
PAYMENT_CURRENCY=EUR
|
||||
LOG_LEVEL=info
|
||||
`;
|
||||
|
||||
// ---- ORIGINAL (pre-edit) versions of changed files ----------------
|
||||
const O = {};
|
||||
|
||||
O["src/Http/Controller/UserController.php"] = `<?php
|
||||
|
||||
namespace App\\Http\\Controller;
|
||||
|
||||
use App\\Service\\PaymentService;
|
||||
use App\\Repository\\UserRepository;
|
||||
use Psr\\Http\\Message\\ResponseInterface;
|
||||
use Psr\\Http\\Message\\ServerRequestInterface;
|
||||
|
||||
final class UserController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $users,
|
||||
private readonly PaymentService $payments,
|
||||
) {}
|
||||
|
||||
public function show(ServerRequestInterface $request, string $id): ResponseInterface
|
||||
{
|
||||
$user = $this->users->find($id);
|
||||
|
||||
if ($user === null) {
|
||||
return $this->json(['error' => 'user_not_found'], 404);
|
||||
}
|
||||
|
||||
$balance = $this->payments->balanceFor($user);
|
||||
|
||||
return $this->json([
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'plan' => $user->plan,
|
||||
'balance' => $balance->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(ServerRequestInterface $request, string $id): ResponseInterface
|
||||
{
|
||||
$user = $this->users->find($id);
|
||||
if ($user === null) {
|
||||
return $this->json(['error' => 'user_not_found'], 404);
|
||||
}
|
||||
|
||||
$data = (array) $request->getParsedBody();
|
||||
$user->fill($this->onlyFillable($data));
|
||||
$this->users->save($user);
|
||||
|
||||
return $this->json($user->toArray());
|
||||
}
|
||||
|
||||
private function onlyFillable(array $data): array
|
||||
{
|
||||
return array_intersect_key($data, array_flip(['email', 'plan']));
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
O["public/assets/app.js"] = `import { createStore } from './store.js';
|
||||
import { mountRouter } from './router.js';
|
||||
|
||||
const store = createStore({
|
||||
user: null,
|
||||
notifications: [],
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const res = await fetch('/api/session');
|
||||
if (res.ok) {
|
||||
const session = await res.json();
|
||||
store.set('user', session.user);
|
||||
}
|
||||
|
||||
mountRouter(document.querySelector('#app'), store);
|
||||
store.subscribe('notifications', renderToasts);
|
||||
}
|
||||
|
||||
function renderToasts(list) {
|
||||
const host = document.querySelector('#toasts');
|
||||
host.replaceChildren(...list.map((n) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = \\\`toast toast--\\\${n.level}\\\`;
|
||||
el.textContent = n.message;
|
||||
return el;
|
||||
}));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', bootstrap);
|
||||
`;
|
||||
|
||||
O["config/app.json"] = `{
|
||||
"name": "console",
|
||||
"env": "production",
|
||||
"features": {
|
||||
"billing": true,
|
||||
"newDashboard": false
|
||||
},
|
||||
"payment": {
|
||||
"gateway": "stripe",
|
||||
"currency": "EUR",
|
||||
"retryLimit": 3
|
||||
},
|
||||
"logging": {
|
||||
"level": "info",
|
||||
"channel": "stdout"
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
O["scripts/migrate.py"] = `#!/usr/bin/env python3
|
||||
"""Run pending database migrations in order."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from db import connect, applied_migrations
|
||||
|
||||
MIGRATIONS = Path(__file__).parent / "migrations"
|
||||
|
||||
|
||||
def pending(conn):
|
||||
done = applied_migrations(conn)
|
||||
files = sorted(MIGRATIONS.glob("*.sql"))
|
||||
return [f for f in files if f.stem not in done]
|
||||
|
||||
|
||||
def run(conn, migration: Path) -> None:
|
||||
sql = migration.read_text()
|
||||
print(f" -> applying {migration.stem}")
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
cur.execute(
|
||||
"INSERT INTO schema_migrations (version) VALUES (%s)",
|
||||
(migration.stem,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
conn = connect()
|
||||
todo = pending(conn)
|
||||
if not todo:
|
||||
print("Database is up to date.")
|
||||
return 0
|
||||
for migration in todo:
|
||||
run(conn, migration)
|
||||
print("Done.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
`;
|
||||
|
||||
// PaymentService is a brand-new file (added) -> original is empty
|
||||
O["src/Service/PaymentService.php"] = "";
|
||||
|
||||
// LegacyUser was deleted -> original content, no working-tree version
|
||||
O["src/Model/LegacyUser.php"] = `<?php
|
||||
|
||||
namespace App\\Model;
|
||||
|
||||
/**
|
||||
* @deprecated Superseded by App\\Entity\\User. Kept only for the
|
||||
* legacy billing import; safe to remove once the importer is gone.
|
||||
*/
|
||||
final class LegacyUser
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $id,
|
||||
public readonly string $email,
|
||||
public readonly ?string $plan = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
return new self(
|
||||
(int) $row['id'],
|
||||
(string) $row['email'],
|
||||
$row['plan'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'email' => $this->email,
|
||||
'plan' => $this->plan,
|
||||
];
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- file tree (nested) -------------------------------------------
|
||||
const tree = {
|
||||
name: "console", type: "dir", path: "", open: true, children: [
|
||||
{ name: "config", type: "dir", path: "config", open: false, children: [
|
||||
{ name: "app.json", type: "file", path: "config/app.json" },
|
||||
]},
|
||||
{ name: "public", type: "dir", path: "public", open: true, children: [
|
||||
{ name: "assets", type: "dir", path: "public/assets", open: true, children: [
|
||||
{ name: "app.js", type: "file", path: "public/assets/app.js" },
|
||||
{ name: "store.js", type: "file", path: "public/assets/store.js" },
|
||||
{ name: "styles.css", type: "file", path: "public/assets/styles.css" },
|
||||
]},
|
||||
]},
|
||||
{ name: "scripts", type: "dir", path: "scripts", open: false, children: [
|
||||
{ name: "migrate.py", type: "file", path: "scripts/migrate.py" },
|
||||
{ name: "seed.py", type: "file", path: "scripts/seed.py" },
|
||||
]},
|
||||
{ name: "src", type: "dir", path: "src", open: true, children: [
|
||||
{ name: "Http", type: "dir", path: "src/Http", open: true, children: [
|
||||
{ name: "Controller", type: "dir", path: "src/Http/Controller", open: true, children: [
|
||||
{ name: "UserController.php", type: "file", path: "src/Http/Controller/UserController.php" },
|
||||
]},
|
||||
]},
|
||||
{ name: "Service", type: "dir", path: "src/Service", open: true, children: [
|
||||
{ name: "PaymentService.php", type: "file", path: "src/Service/PaymentService.php" },
|
||||
]},
|
||||
{ name: "types", type: "dir", path: "src/types", open: false, children: [
|
||||
{ name: "api.ts", type: "file", path: "src/types/api.ts" },
|
||||
]},
|
||||
]},
|
||||
{ name: "templates", type: "dir", path: "templates", open: false, children: [
|
||||
{ name: "dashboard.html", type: "file", path: "templates/dashboard.html" },
|
||||
]},
|
||||
{ name: ".env", type: "file", path: ".env" },
|
||||
{ name: "composer.json", type: "file", path: "composer.json" },
|
||||
{ name: "package.json", type: "file", path: "package.json" },
|
||||
{ name: "README.md", type: "file", path: "README.md" },
|
||||
],
|
||||
};
|
||||
|
||||
// ---- line-based LCS diff ------------------------------------------
|
||||
function buildDiff(origText, updText) {
|
||||
const a = origText === "" ? [] : origText.replace(/\n$/, "").split("\n");
|
||||
const b = updText === "" ? [] : updText.replace(/\n$/, "").split("\n");
|
||||
const n = a.length, m = b.length;
|
||||
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i--)
|
||||
for (let j = m - 1; j >= 0; j--)
|
||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
|
||||
const ops = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) { ops.push({ t: "same", a: i, b: j }); i++; j++; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: "del", a: i }); i++; }
|
||||
else { ops.push({ t: "add", b: j }); j++; }
|
||||
}
|
||||
while (i < n) { ops.push({ t: "del", a: i++ }); }
|
||||
while (j < m) { ops.push({ t: "add", b: j++ }); }
|
||||
|
||||
const rows = [], left = [], right = [], split = [];
|
||||
const delSet = new Set(), addSet = new Set();
|
||||
let add = 0, del = 0;
|
||||
for (const op of ops) {
|
||||
if (op.t === "same") {
|
||||
rows.push({ sign: " ", oldNo: op.a + 1, newNo: op.b + 1, text: a[op.a] });
|
||||
} else if (op.t === "del") {
|
||||
rows.push({ sign: "-", oldNo: op.a + 1, newNo: null, text: a[op.a] });
|
||||
delSet.add(op.a); del++;
|
||||
} else {
|
||||
rows.push({ sign: "+", oldNo: null, newNo: op.b + 1, text: b[op.b] });
|
||||
addSet.add(op.b); add++;
|
||||
}
|
||||
}
|
||||
a.forEach((text, idx) => left.push({ no: idx + 1, text, mark: delSet.has(idx) ? "del" : null }));
|
||||
b.forEach((text, idx) => right.push({ no: idx + 1, text, mark: addSet.has(idx) ? "add" : null }));
|
||||
|
||||
// aligned split rows (pair del/add blocks)
|
||||
let dbuf = [], abuf = [];
|
||||
const flush = () => {
|
||||
const k = Math.max(dbuf.length, abuf.length);
|
||||
for (let x = 0; x < k; x++) split.push({ l: dbuf[x] || null, r: abuf[x] || null });
|
||||
dbuf = []; abuf = [];
|
||||
};
|
||||
for (const op of ops) {
|
||||
if (op.t === "same") { flush(); split.push({ l: { no: op.a + 1, text: a[op.a] }, r: { no: op.b + 1, text: b[op.b] } }); }
|
||||
else if (op.t === "del") dbuf.push({ no: op.a + 1, text: a[op.a], mark: "del" });
|
||||
else abuf.push({ no: op.b + 1, text: b[op.b], mark: "add" });
|
||||
}
|
||||
flush();
|
||||
|
||||
return { rows, left, right, split, add, del };
|
||||
}
|
||||
|
||||
// ---- changed files -------------------------------------------------
|
||||
const changeDefs = [
|
||||
{ path: "src/Service/PaymentService.php", status: "A" },
|
||||
{ path: "src/Http/Controller/UserController.php", status: "M" },
|
||||
{ path: "public/assets/app.js", status: "M" },
|
||||
{ path: "config/app.json", status: "M" },
|
||||
{ path: "scripts/migrate.py", status: "M" },
|
||||
{ path: "src/Model/LegacyUser.php", status: "D" },
|
||||
];
|
||||
|
||||
const diffs = {};
|
||||
const changes = changeDefs.map((c) => {
|
||||
const orig = O[c.path] != null ? O[c.path] : "";
|
||||
const upd = F[c.path] != null ? F[c.path] : "";
|
||||
const d = buildDiff(orig, upd);
|
||||
diffs[c.path] = Object.assign(d, {
|
||||
deleted: c.status === "D",
|
||||
added: c.status === "A",
|
||||
original: orig,
|
||||
updated: upd,
|
||||
});
|
||||
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === "D" };
|
||||
});
|
||||
|
||||
window.PROJECT = {
|
||||
name: "console",
|
||||
branch: "feat/payments-balance",
|
||||
files: F,
|
||||
originals: O,
|
||||
tree,
|
||||
diffs,
|
||||
changes,
|
||||
};
|
||||
})();
|
||||
272
design_handoff_helder_workbench/design/src/editor.jsx
Normal file
272
design_handoff_helder_workbench/design/src/editor.jsx
Normal file
@@ -0,0 +1,272 @@
|
||||
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
|
||||
|
||||
function climbToLine(node) {
|
||||
let el = node && node.nodeType === 3 ? node.parentElement : node;
|
||||
while (el && !(el.dataset && el.dataset.line)) el = el.parentElement;
|
||||
return el || null;
|
||||
}
|
||||
|
||||
function EditorTabs({ tabs, active, onActivate, onClose }) {
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current && ref.current.querySelector(".tab.active");
|
||||
if (el) el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}, [active]);
|
||||
return (
|
||||
<div className="tabs" ref={ref}>
|
||||
{tabs.map((t) => {
|
||||
const name = t.path.split("/").pop();
|
||||
return (
|
||||
<div key={t.path}
|
||||
className={"tab" + (active === t.path ? " active" : "")}
|
||||
onClick={() => onActivate(t.path)}
|
||||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path); } }}
|
||||
title={t.path}>
|
||||
<FileIcon path={t.path} />
|
||||
<span className="tname">{name}</span>
|
||||
{t.changed && <span className="tab-mode">{t.modeLabel}</span>}
|
||||
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path); }}>
|
||||
{Icon.close({})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
|
||||
function PaneView({ cacheKey, path, lines, lang, showSign, refLine, cursor, selection, setCursor, setSelection, onContext }) {
|
||||
const anchorRef = useRef(null);
|
||||
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey]);
|
||||
|
||||
function gutterClick(e, no) {
|
||||
if (no == null) return;
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey && anchorRef.current != null) {
|
||||
const a = anchorRef.current;
|
||||
setSelection({ path, start: Math.min(a, no), end: Math.max(a, no), anchor: a });
|
||||
} else {
|
||||
anchorRef.current = no;
|
||||
setSelection({ path, start: no, end: no, anchor: no });
|
||||
}
|
||||
setCursor({ path, line: no, col: 1 });
|
||||
}
|
||||
function caretCol(sel) {
|
||||
try {
|
||||
const el = climbToLine(sel.focusNode);
|
||||
const code = el.querySelector(".ln-code");
|
||||
const r = document.createRange();
|
||||
r.setStart(code, 0); r.setEnd(sel.focusNode, sel.focusOffset);
|
||||
return r.toString().length + 1;
|
||||
} catch (e) { return 1; }
|
||||
}
|
||||
function onMouseUp() {
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) {
|
||||
const a = climbToLine(sel.anchorNode), f = climbToLine(sel.focusNode);
|
||||
if (a && f) {
|
||||
const an = +a.dataset.line, fn = +f.dataset.line;
|
||||
const s = Math.min(an, fn), e = Math.max(an, fn);
|
||||
if (s !== e) { setSelection({ path, start: s, end: e, anchor: an }); setCursor({ path, line: fn, col: caretCol(sel) }); return; }
|
||||
}
|
||||
}
|
||||
if (sel && sel.focusNode) {
|
||||
const el = climbToLine(sel.focusNode);
|
||||
if (el) { setCursor({ path, line: +el.dataset.line, col: caretCol(sel) }); setSelection(null); }
|
||||
}
|
||||
}
|
||||
function handleContext(e) {
|
||||
e.preventDefault();
|
||||
const sel = window.getSelection();
|
||||
let info = { path, kind: "editor" };
|
||||
const a = sel && sel.anchorNode && climbToLine(sel.anchorNode);
|
||||
const f = sel && sel.focusNode && climbToLine(sel.focusNode);
|
||||
if (sel && !sel.isCollapsed && a && f && +a.dataset.line !== +f.dataset.line) {
|
||||
const s = Math.min(+a.dataset.line, +f.dataset.line), en = Math.max(+a.dataset.line, +f.dataset.line);
|
||||
info.sel = { start: s, end: en }; info.line = s;
|
||||
} else if (selection && selection.path === path && selection.start !== selection.end) {
|
||||
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start;
|
||||
} else {
|
||||
let no = null;
|
||||
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
|
||||
const el = r && climbToLine(r.startContainer);
|
||||
if (el) no = +el.dataset.line;
|
||||
info.line = no || (cursor && cursor.path === path ? cursor.line : 1);
|
||||
}
|
||||
setCursor({ path, line: info.line, col: 1 });
|
||||
onContext(e, info);
|
||||
}
|
||||
|
||||
const curLine = cursor && cursor.path === path ? cursor.line : -1;
|
||||
const sel = selection && selection.path === path ? selection : null;
|
||||
|
||||
return (
|
||||
<div className={"editor" + (showSign ? " diff" : "")} onMouseUp={onMouseUp} onContextMenu={handleContext}>
|
||||
{lines.map((l, i) => {
|
||||
const no = l.no;
|
||||
const inSel = sel && no != null && no >= sel.start && no <= sel.end;
|
||||
const cls = "ln-row"
|
||||
+ (l.row === "add" ? " add" : l.row === "del" ? " del" : "")
|
||||
+ (l.row === "bar-add" ? " bar-add" : l.row === "bar-del" ? " bar-del" : "")
|
||||
+ (no === curLine && !inSel && !l.row ? " cursor" : "")
|
||||
+ (inSel ? " selrange" : "");
|
||||
return (
|
||||
<div key={i} data-line={no == null ? undefined : no} className={cls}>
|
||||
<span className="ln-gutter" onClick={(e) => gutterClick(e, no)}>{no == null ? "" : no}</span>
|
||||
{showSign && <span className="ln-sign">{l.sign === " " || !l.sign ? "" : l.sign}</span>}
|
||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: html[i] }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Build the line descriptors for a given mode. */
|
||||
function buildLines(mode, diff, fileText) {
|
||||
if (mode === "original") return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === "del" ? "bar-del" : null })), showSign: false };
|
||||
if (mode === "updated") return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === "add" ? "bar-add" : null })), showSign: false };
|
||||
if (mode === "diff") return { lines: diff.rows.map((r) => ({ no: r.newNo || r.oldNo, text: r.text, sign: r.sign, row: r.sign === "+" ? "add" : r.sign === "-" ? "del" : null })), showSign: true };
|
||||
// plain file
|
||||
const arr = (fileText || "").replace(/\n$/, "").split("\n");
|
||||
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false };
|
||||
}
|
||||
|
||||
const SEGMENTS = [
|
||||
{ id: "original", label: "Original" },
|
||||
{ id: "updated", label: "Updated" },
|
||||
{ id: "diff", label: "Diff" },
|
||||
];
|
||||
|
||||
function Editor({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection }) {
|
||||
const tab = tabs.find((t) => t.path === active);
|
||||
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null;
|
||||
const diff = tab ? PROJECT.diffs[tab.path] : null;
|
||||
const lang = tab ? HL.langFor(tab.path) : null;
|
||||
const effMode = change ? mode : "code";
|
||||
|
||||
let built = null;
|
||||
if (tab) {
|
||||
if (change && diff) built = buildLines(effMode, diff, PROJECT.files[tab.path]);
|
||||
else built = buildLines("code", null, PROJECT.files[tab.path]);
|
||||
}
|
||||
|
||||
const statusWord = change ? (change.status === "A" ? "Added" : change.status === "D" ? "Deleted" : "Modified") : "";
|
||||
const activeSeg = splitOpen ? "split" : effMode;
|
||||
const emptyUpdated = effMode === "updated" && built && built.lines.length === 0;
|
||||
const emptyOriginal = effMode === "original" && built && built.lines.length === 0;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
|
||||
{!tab ? (
|
||||
<div className="empty-ed">
|
||||
<div style={{ opacity: .5 }}>{Icon.file({ width: 30, height: 30 })}</div>
|
||||
<div className="big">No file open</div>
|
||||
<div className="klist">
|
||||
<div><span>Search files & content</span><kbd>⌘ F</kbd></div>
|
||||
<div><span>Copy reference</span><kbd>right-click</kbd></div>
|
||||
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="editor-wrap">
|
||||
{change && (
|
||||
<div className="diff-bar">
|
||||
<span className={"git-stat " + change.status} style={{ width: "auto" }}>{statusWord}</span>
|
||||
{change.add > 0 && <span className="a">+{change.add}</span>}
|
||||
{change.del > 0 && <span className="d">−{change.del}</span>}
|
||||
<div className="seg">
|
||||
{SEGMENTS.map((s) => (
|
||||
<button key={s.id} className={activeSeg === s.id ? "on" : ""} onClick={() => setMode(s.id)}>{s.label}</button>
|
||||
))}
|
||||
<button className={"split-btn" + (activeSeg === "split" ? " on" : "")} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2"/><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2"/></svg>
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{emptyUpdated ? (
|
||||
<div className="empty-ed"><div className="big" style={{ color: "var(--del)" }}>No updated version</div><div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--fg-3)" }}>This file was deleted in the change.</div></div>
|
||||
) : emptyOriginal ? (
|
||||
<div className="empty-ed"><div className="big" style={{ color: "var(--add)" }}>No original version</div><div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--fg-3)" }}>This file is new in the change.</div></div>
|
||||
) : (
|
||||
<PaneView cacheKey={tab.path + ":" + effMode} path={tab.path} lines={built.lines}
|
||||
lang={lang} showSign={built.showSign} cursor={cursor} selection={selection}
|
||||
setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
/* Full-screen side-by-side split view */
|
||||
function SplitView({ path, onClose, onContext }) {
|
||||
const diff = PROJECT.diffs[path];
|
||||
const lang = HL.langFor(path);
|
||||
const leftRef = useRef(null), rightRef = useRef(null);
|
||||
const lock = useRef(false);
|
||||
const change = PROJECT.changes.find((c) => c.path === path);
|
||||
|
||||
const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ""), [path]);
|
||||
const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ""), [path]);
|
||||
|
||||
function sync(from, to) {
|
||||
if (lock.current) return; lock.current = true;
|
||||
to.scrollTop = from.scrollTop; to.scrollLeft = from.scrollLeft;
|
||||
requestAnimationFrame(() => { lock.current = false; });
|
||||
}
|
||||
function ctx(e, side) {
|
||||
e.preventDefault();
|
||||
let no = null;
|
||||
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
|
||||
const el = r && climbToLine(r.startContainer);
|
||||
if (el) no = +el.dataset.line;
|
||||
onContext(e, { path, kind: "editor", line: no || 1 });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="split-overlay">
|
||||
<div className="split-head">
|
||||
<FileIcon path={path} />
|
||||
<span className="sh-name">{path}</span>
|
||||
{change && <span className={"git-stat " + change.status} style={{ width: "auto" }}>{change.status === "A" ? "Added" : change.status === "D" ? "Deleted" : "Modified"}</span>}
|
||||
{change && change.add > 0 && <span className="a" style={{ fontFamily: "var(--mono)", color: "var(--add)" }}>+{change.add}</span>}
|
||||
{change && change.del > 0 && <span className="d" style={{ fontFamily: "var(--mono)", color: "var(--del)" }}>−{change.del}</span>}
|
||||
<button className="split-exit" onClick={onClose}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7 1.5h3.5V5M5 10.5H1.5V7M10.5 1.5L7 5M1.5 10.5L5 7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
Collapse <kbd>Esc</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<div className="split-body">
|
||||
<div className="split-pane left">
|
||||
<div className="split-label">Original <span>before</span></div>
|
||||
<div className="editor" ref={leftRef} onScroll={() => sync(leftRef.current, rightRef.current)} onContextMenu={(e) => ctx(e, "l")}>
|
||||
{diff.split.map((row, i) => (
|
||||
<div key={i} data-line={row.l ? row.l.no : undefined} className={"ln-row" + (row.l && row.l.mark === "del" ? " bar-del" : "") + (!row.l ? " empty" : "")}>
|
||||
<span className="ln-gutter">{row.l ? row.l.no : ""}</span>
|
||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.l ? leftHtml[i] : "" }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="split-pane right">
|
||||
<div className="split-label">Updated <span>after</span></div>
|
||||
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={(e) => ctx(e, "r")}>
|
||||
{diff.split.map((row, i) => (
|
||||
<div key={i} data-line={row.r ? row.r.no : undefined} className={"ln-row" + (row.r && row.r.mark === "add" ? " bar-add" : "") + (!row.r ? " empty" : "")}>
|
||||
<span className="ln-gutter">{row.r ? row.r.no : ""}</span>
|
||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.r ? rightHtml[i] : "" }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Editor, EditorTabs, PaneView, SplitView, buildLines, climbToLine });
|
||||
79
design_handoff_helder_workbench/design/src/highlight.js
Normal file
79
design_handoff_helder_workbench/design/src/highlight.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/* Syntax highlighting (Prism) + file-type icon metadata. */
|
||||
(function () {
|
||||
const EXT_LANG = {
|
||||
php: "php", js: "javascript", mjs: "javascript", cjs: "javascript",
|
||||
jsx: "jsx", ts: "typescript", tsx: "tsx", py: "python",
|
||||
html: "markup", xml: "markup", svg: "markup", vue: "markup",
|
||||
css: "css", scss: "css", json: "json", md: "markdown",
|
||||
sh: "bash", bash: "bash", yml: "yaml", yaml: "yaml", env: "bash",
|
||||
};
|
||||
|
||||
function ext(path) {
|
||||
const base = path.split("/").pop() || "";
|
||||
if (base === ".env" || base.startsWith(".env")) return "env";
|
||||
const i = base.lastIndexOf(".");
|
||||
return i >= 0 ? base.slice(i + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
function langFor(path) { return EXT_LANG[ext(path)] || null; }
|
||||
|
||||
function langLabel(path) {
|
||||
const e = ext(path);
|
||||
const map = {
|
||||
php: "PHP", js: "JavaScript", mjs: "JavaScript", ts: "TypeScript",
|
||||
tsx: "TypeScript", jsx: "JavaScript", py: "Python", html: "HTML",
|
||||
css: "CSS", json: "JSON", md: "Markdown", sh: "Shell", env: "Dotenv",
|
||||
yml: "YAML", yaml: "YAML",
|
||||
};
|
||||
return map[e] || (e ? e.toUpperCase() : "Plain Text");
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||
}
|
||||
|
||||
// highlight a single line independently (keeps line numbering robust)
|
||||
function hlLine(line, lang) {
|
||||
if (line === "") return " ";
|
||||
try {
|
||||
const grammar = lang && window.Prism && Prism.languages[lang];
|
||||
if (grammar) return Prism.highlight(line, grammar, lang);
|
||||
} catch (e) { /* fall through */ }
|
||||
return escapeHtml(line);
|
||||
}
|
||||
|
||||
// ---- file-type icon: colored monogram chip --------------------------
|
||||
const ICONS = {
|
||||
php: { c: "#a78bdb", t: "php" },
|
||||
js: { c: "#e6c860", t: "js" },
|
||||
mjs: { c: "#e6c860", t: "js" },
|
||||
ts: { c: "#5a9bd6", t: "ts" },
|
||||
tsx: { c: "#5a9bd6", t: "ts" },
|
||||
jsx: { c: "#5a9bd6", t: "jsx" },
|
||||
py: { c: "#5fa8d6", t: "py" },
|
||||
html: { c: "#e08b6a", t: "<>" },
|
||||
css: { c: "#5a9bd6", t: "{}" },
|
||||
scss: { c: "#d6699e", t: "{}" },
|
||||
json: { c: "#d8a85c", t: "{}" },
|
||||
md: { c: "#9aa0a8", t: "md" },
|
||||
env: { c: "#7fc6a0", t: "$" },
|
||||
sh: { c: "#7fc6a0", t: "$" },
|
||||
yml: { c: "#cf7a6a", t: "yml" },
|
||||
yaml: { c: "#cf7a6a", t: "yml" },
|
||||
lock: { c: "#8a8f98", t: "lk" },
|
||||
};
|
||||
const NAME_ICONS = {
|
||||
"composer.json": { c: "#a78bdb", t: "co" },
|
||||
"package.json": { c: "#cf7a6a", t: "pk" },
|
||||
"README.md": { c: "#5a9bd6", t: "md" },
|
||||
".env": { c: "#7fc6a0", t: "$" },
|
||||
};
|
||||
|
||||
function iconFor(path) {
|
||||
const base = path.split("/").pop() || "";
|
||||
if (NAME_ICONS[base]) return NAME_ICONS[base];
|
||||
return ICONS[ext(path)] || { c: "#7d838c", t: base.slice(0, 2) || "·" };
|
||||
}
|
||||
|
||||
window.HL = { ext, langFor, langLabel, hlLine, iconFor, escapeHtml };
|
||||
})();
|
||||
226
design_handoff_helder_workbench/design/src/overlays.jsx
Normal file
226
design_handoff_helder_workbench/design/src/overlays.jsx
Normal file
@@ -0,0 +1,226 @@
|
||||
/* Overlays: command palette (fuzzy file finder), content search, context menu, toast */
|
||||
|
||||
function fuzzy(q, str) {
|
||||
q = q.toLowerCase(); const s = str.toLowerCase();
|
||||
let i = 0; const idx = [];
|
||||
for (let j = 0; j < s.length && i < q.length; j++) {
|
||||
if (s[j] === q[i]) { idx.push(j); i++; }
|
||||
}
|
||||
return i === q.length ? idx : null;
|
||||
}
|
||||
|
||||
function Highlight({ text, idx }) {
|
||||
if (!idx || !idx.length) return <span>{text}</span>;
|
||||
const set = new Set(idx);
|
||||
return <span>{text.split("").map((ch, i) => set.has(i) ? <b key={i}>{ch}</b> : <React.Fragment key={i}>{ch}</React.Fragment>)}</span>;
|
||||
}
|
||||
|
||||
function SearchModal({ onOpen, onOpenAt, onClose, changeSet }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [sel, setSel] = useState(0);
|
||||
const inputRef = useRef(null);
|
||||
const leftRef = useRef(null);
|
||||
const allPaths = useMemo(() => Object.keys(PROJECT.files), []);
|
||||
|
||||
useEffect(() => { inputRef.current && inputRef.current.focus(); }, []);
|
||||
|
||||
// content hits (left)
|
||||
const content = useMemo(() => {
|
||||
const term = q.trim();
|
||||
if (term.length < 2) return [];
|
||||
const low = term.toLowerCase();
|
||||
const groups = [];
|
||||
for (const [path, src] of Object.entries(PROJECT.files)) {
|
||||
const lines = src.split("\n");
|
||||
const hits = [];
|
||||
lines.forEach((ln, i) => {
|
||||
const ix = ln.toLowerCase().indexOf(low);
|
||||
if (ix >= 0) hits.push({ no: i + 1, ln, ix });
|
||||
});
|
||||
if (hits.length) groups.push({ path, hits });
|
||||
}
|
||||
return groups;
|
||||
}, [q]);
|
||||
|
||||
// file-name matches (right)
|
||||
const files = useMemo(() => {
|
||||
const term = q.trim();
|
||||
if (!term) return [];
|
||||
const out = [];
|
||||
for (const p of allPaths) {
|
||||
const name = p.split("/").pop();
|
||||
const ni = fuzzy(term, name);
|
||||
if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue; }
|
||||
const pi = fuzzy(term, p);
|
||||
if (pi) out.push({ path: p, idx: null, rank: 1, pos: pi[0] });
|
||||
}
|
||||
out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length);
|
||||
return out;
|
||||
}, [q]);
|
||||
|
||||
// flat list of content hits for keyboard nav
|
||||
const flat = useMemo(() => {
|
||||
const arr = [];
|
||||
content.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })));
|
||||
return arr;
|
||||
}, [content]);
|
||||
|
||||
const totalHits = flat.length;
|
||||
useEffect(() => { setSel(0); }, [q]);
|
||||
useEffect(() => {
|
||||
const el = leftRef.current && leftRef.current.querySelector(".sr-line.sel");
|
||||
if (el) el.scrollIntoView({ block: "nearest" });
|
||||
}, [sel]);
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(s + 1, flat.length - 1)); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)); }
|
||||
else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose(); }
|
||||
else if (files[0]) { onOpen(files[0].path); onClose(); }
|
||||
} else if (e.key === "Escape") { e.preventDefault(); onClose(); }
|
||||
}
|
||||
|
||||
function renderLine(ln, ix, len) {
|
||||
const pre = ln.slice(0, ix), mid = ln.slice(ix, ix + len), post = ln.slice(ix + len);
|
||||
return <span className="tx">{pre}<mark>{mid}</mark>{post}</span>;
|
||||
}
|
||||
|
||||
const term = q.trim();
|
||||
let flatIx = -1;
|
||||
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="search-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.search({ style: { color: "var(--fg-3)" } })}
|
||||
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey}
|
||||
placeholder="Search content and file names…" spellCheck={false} />
|
||||
<span className="mode-chip">{totalHits} hit{totalHits === 1 ? "" : "s"} · {files.length} file{files.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<div className="search-cols">
|
||||
<div className="sc-left" ref={leftRef}>
|
||||
<div className="sc-head">Content {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div>
|
||||
{term.length < 2 && <div className="pempty">Type at least 2 characters</div>}
|
||||
{term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>}
|
||||
{content.map((g) => (
|
||||
<React.Fragment key={g.path}>
|
||||
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
|
||||
<FileIcon path={g.path} />
|
||||
<span className="srf-name">{g.path}</span>
|
||||
<span className="cnt">{g.hits.length}</span>
|
||||
</div>
|
||||
{g.hits.slice(0, 12).map((h) => {
|
||||
flatIx++;
|
||||
const me = flatIx;
|
||||
return (
|
||||
<div key={h.no} className={"sr-line" + (me === sel ? " sel" : "")}
|
||||
onMouseEnter={() => setSel(me)}
|
||||
onClick={() => { onOpenAt(g.path, h.no); onClose(); }}>
|
||||
<span className="no">{h.no}</span>
|
||||
{renderLine(h.ln, h.ix, term.length)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="sc-right">
|
||||
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>}</div>
|
||||
{!term && <div className="pempty sm">Start typing…</div>}
|
||||
{term && files.length === 0 && <div className="pempty sm">No file names match</div>}
|
||||
{files.slice(0, 40).map((r) => {
|
||||
const name = r.path.split("/").pop();
|
||||
const dir = r.path.split("/").slice(0, -1).join("/");
|
||||
return (
|
||||
<div key={r.path} className="fres" onClick={() => { onOpen(r.path); onClose(); }} title={r.path}>
|
||||
<FileIcon path={r.path} />
|
||||
<div className="fres-txt">
|
||||
<span className="fn"><Highlight text={name} idx={r.idx} /></span>
|
||||
{dir && <span className="fd">{dir}/</span>}
|
||||
</div>
|
||||
{changeSet.has(r.path) && <span className="tree-badge M" style={{ fontFamily: "var(--mono)", fontSize: 10 }}>•</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenu({ menu, onClose }) {
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
|
||||
const k = (e) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("mousedown", h);
|
||||
document.addEventListener("keydown", k);
|
||||
return () => { document.removeEventListener("mousedown", h); document.removeEventListener("keydown", k); };
|
||||
}, []);
|
||||
if (!menu) return null;
|
||||
const x = Math.min(menu.x, window.innerWidth - 270);
|
||||
const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60));
|
||||
return (
|
||||
<div className="ctx" ref={ref} style={{ left: x, top: y }}>
|
||||
{menu.note && <div className="ctx-note">{menu.note}</div>}
|
||||
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
|
||||
<div key={i} className={"ctx-item" + (it.primary ? " primary" : "")}
|
||||
onClick={() => { it.onClick(); onClose(); }}>
|
||||
<span className="ic">{it.icon}</span>
|
||||
<span>{it.label}</span>
|
||||
{it.kbd && <span className="kc">{it.kbd}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toasts({ toasts }) {
|
||||
return (
|
||||
<div className="toast-wrap">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className="toast">
|
||||
{Icon.copy({ style: { color: "var(--accent)" } })}
|
||||
<span className="tt">{t.title}</span>
|
||||
{t.ref && <span className="tref">{t.ref}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PassPopup({ x, y, refStr, onConfirm, onCancel }) {
|
||||
const [text, setText] = useState("");
|
||||
const inputRef = useRef(null);
|
||||
const boxRef = useRef(null);
|
||||
useEffect(() => { inputRef.current && inputRef.current.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const h = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) onCancel(); };
|
||||
const k = (e) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); } };
|
||||
document.addEventListener("mousedown", h);
|
||||
document.addEventListener("keydown", k, true);
|
||||
return () => { document.removeEventListener("mousedown", h); document.removeEventListener("keydown", k, true); };
|
||||
}, []);
|
||||
const left = Math.min(x, window.innerWidth - 360);
|
||||
const top = Math.min(y + 6, window.innerHeight - 150);
|
||||
const preview = (text.trim() ? text.trim() + " " : "") + refStr;
|
||||
return (
|
||||
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
|
||||
<div className="pass-head">{Icon.spark({})}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
|
||||
<input ref={inputRef} className="pass-input" value={text} spellCheck={false}
|
||||
placeholder="Add a note (optional)…"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); onConfirm(text); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); onCancel(); }
|
||||
}} />
|
||||
<div className="pass-preview"><span className="pp-lbl">inserts</span><code>{preview}</code></div>
|
||||
<div className="pass-foot"><kbd>↵</kbd> insert into agent · <kbd>esc</kbd> cancel</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { SearchModal, ContextMenu, Toasts, PassPopup, fuzzy });
|
||||
231
design_handoff_helder_workbench/design/src/terminals.jsx
Normal file
231
design_handoff_helder_workbench/design/src/terminals.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
/* Terminals: a generic shell that boots an (original-styled) AI agent session via `claude`. */
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let _lid = 0;
|
||||
const lid = () => ++_lid;
|
||||
|
||||
function Terminal({ kind, seed }) {
|
||||
const [lines, setLines] = useState(seed.lines);
|
||||
const [mode, setMode] = useState(seed.mode);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const bodyRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const hist = useRef([]);
|
||||
const histIx = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const el = bodyRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines, busy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "agent") return;
|
||||
const h = (e) => {
|
||||
if (mode !== "agent") { pushMany(bootAgent()); setMode("agent"); }
|
||||
// bracketed-paste semantics: append as a new, UNSUBMITTED line; leave caret on a fresh line
|
||||
setInput((prev) => {
|
||||
const base = prev.replace(/\n+$/, "");
|
||||
return (base ? base + "\n" : "") + e.detail + "\n";
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) { el.focus(); const v = el.value.length; el.setSelectionRange(v, v); }
|
||||
});
|
||||
};
|
||||
window.addEventListener("agentPaste", h);
|
||||
return () => window.removeEventListener("agentPaste", h);
|
||||
}, [kind, mode]);
|
||||
|
||||
const push = (line) => setLines((p) => [...p, { id: lid(), ...line }]);
|
||||
const pushMany = (arr) => setLines((p) => [...p, ...arr.map((l) => ({ id: lid(), ...l }))]);
|
||||
|
||||
async function runAgent(prompt) {
|
||||
setBusy(true);
|
||||
push({ kind: "agent-think", html: `<span class="ag">●</span> <span class="dim">thinking…</span>` });
|
||||
await wait(520);
|
||||
setLines((p) => p.slice(0, -1)); // drop the thinking line
|
||||
push({ kind: "t", cls: "", html: `<span class="ag">●</span> I'll take a look at the relevant files first.` });
|
||||
await wait(380);
|
||||
push({ kind: "t", cls: "dim", html: ` <span class="tool">read</span> <span class="fp">src/Http/Controller/UserController.php</span>` });
|
||||
await wait(300);
|
||||
push({ kind: "t", cls: "dim", html: ` <span class="tool">grep</span> <span class="dim">"balanceFor" → 2 matches</span>` });
|
||||
await wait(420);
|
||||
push({ kind: "t", cls: "", html: `<span class="ag">●</span> Adding the field and a fillable allow-list. Editing now.` });
|
||||
await wait(360);
|
||||
push({
|
||||
kind: "card", ch: `edit · src/Http/Controller/UserController.php`,
|
||||
rows: [
|
||||
{ cls: "del", t: "- 'plan' => $user->plan," },
|
||||
{ cls: "add", t: "+ 'plan' => $user->plan->value," },
|
||||
{ cls: "add", t: "+ 'currency' => $user->currency," },
|
||||
],
|
||||
});
|
||||
await wait(450);
|
||||
push({ kind: "t", cls: "ok", html: `<span class="ag">●</span> <span class="ok">Done.</span> Updated <span class="fp">UserController.php</span>. Run tests with <span class="fp">composer test</span>.` });
|
||||
setBusy(false);
|
||||
requestAnimationFrame(() => inputRef.current && inputRef.current.focus());
|
||||
}
|
||||
|
||||
function shell(cmd) {
|
||||
const [c, ...rest] = cmd.split(/\s+/);
|
||||
const arg = rest.join(" ");
|
||||
switch (c) {
|
||||
case "": return;
|
||||
case "claude":
|
||||
pushMany(bootAgent());
|
||||
setMode("agent");
|
||||
return;
|
||||
case "clear": setLines([]); return;
|
||||
case "help":
|
||||
pushMany([
|
||||
{ kind: "t", cls: "dim", html: "commands: <span style='color:var(--fg-1)'>claude</span> ls pwd cat <file> git status git diff echo clear" },
|
||||
]); return;
|
||||
case "pwd": push({ kind: "t", html: "/Users/dev/console" }); return;
|
||||
case "ls":
|
||||
push({ kind: "t", html: "<span class='fp'>config</span> <span class='fp'>public</span> <span class='fp'>scripts</span> <span class='fp'>src</span> <span class='fp'>templates</span> composer.json package.json README.md" });
|
||||
return;
|
||||
case "echo": push({ kind: "t", html: HL.escapeHtml(arg) }); return;
|
||||
case "git":
|
||||
if (rest[0] === "status") {
|
||||
pushMany([
|
||||
{ kind: "t", cls: "dim", html: `On branch <span style='color:var(--fg-1)'>${PROJECT.branch}</span>` },
|
||||
{ kind: "t", cls: "dim", html: "Changes to be committed:" },
|
||||
...PROJECT.changes.map((ch) => ({
|
||||
kind: "t",
|
||||
html: ` <span style="color:var(--${ch.status === 'A' ? 'add' : ch.status === 'D' ? 'del' : 'mod'})">${ch.status === 'A' ? 'new file' : ch.status === 'D' ? 'deleted ' : 'modified'}</span> <span class="fp">${ch.path}</span>`,
|
||||
})),
|
||||
]);
|
||||
} else if (rest[0] === "diff") {
|
||||
pushMany([
|
||||
{ kind: "t", cls: "dim", html: "diff --git a/public/assets/app.js b/public/assets/app.js" },
|
||||
{ kind: "t", cls: "err", html: "- const res = await fetch('/api/session');" },
|
||||
{ kind: "t", cls: "ok", html: "+ const res = await fetch('/api/session', { credentials: 'include' });" },
|
||||
]);
|
||||
} else push({ kind: "t", cls: "dim", html: `git: '${rest[0] || ""}' is not handled in this demo` });
|
||||
return;
|
||||
case "cat": {
|
||||
const f = PROJECT.files[arg] || Object.entries(PROJECT.files).find(([p]) => p.endsWith(arg))?.[1];
|
||||
if (!f) { push({ kind: "t", cls: "err", html: `cat: ${HL.escapeHtml(arg)}: No such file` }); return; }
|
||||
pushMany(f.replace(/\n$/, "").split("\n").slice(0, 24).map((l) => ({ kind: "t", cls: "dim", html: HL.escapeHtml(l) || " " })));
|
||||
return;
|
||||
}
|
||||
default: push({ kind: "t", cls: "err", html: `${HL.escapeHtml(c)}: command not found` });
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const cmd = input.trim();
|
||||
if (busy) return;
|
||||
if (cmd) { hist.current.unshift(cmd); }
|
||||
histIx.current = -1;
|
||||
if (mode === "agent") {
|
||||
if (cmd === "/exit" || cmd === "exit") {
|
||||
push({ kind: "t", cls: "dim", html: "<span class='ag'>●</span> Session ended." });
|
||||
setMode("shell"); setInput(""); return;
|
||||
}
|
||||
if (cmd === "/clear" || cmd === "clear") { setLines([]); setInput(""); return; }
|
||||
push({ kind: "t", html: `<span class="ip ag">></span> ${HL.escapeHtml(cmd).replace(/\n/g, "<br> ") || " "}` });
|
||||
setInput("");
|
||||
if (cmd) runAgent(cmd);
|
||||
return;
|
||||
}
|
||||
push({ kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> ${HL.escapeHtml(cmd) || " "}` });
|
||||
setInput("");
|
||||
shell(cmd);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
|
||||
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !input.includes("\n")) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") { if (hist.current.length) { histIx.current = Math.min(histIx.current + 1, hist.current.length - 1); setInput(hist.current[histIx.current]); } }
|
||||
else { if (histIx.current > 0) { histIx.current--; setInput(hist.current[histIx.current]); } else { histIx.current = -1; setInput(""); } }
|
||||
}
|
||||
}
|
||||
|
||||
const live = mode === "agent";
|
||||
return (
|
||||
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => inputRef.current && inputRef.current.focus()}>
|
||||
<div className="term-head">
|
||||
<span className={"dot" + (live ? " live" : "")}></span>
|
||||
<span className="lbl">{live ? "claude" : kind === "agent" ? "claude" : "zsh"}</span>
|
||||
<span className="tag">{live ? "agent session" : "— bash · ~/console"}</span>
|
||||
</div>
|
||||
<div className="term-body" ref={bodyRef}>
|
||||
{lines.map((l) => {
|
||||
if (l.kind === "card") {
|
||||
return (
|
||||
<div key={l.id} className="term-card">
|
||||
<div className="ch">{Icon.diff({ width: 11, height: 11 })}<span>{l.ch}</span></div>
|
||||
{l.rows.map((r, i) => (<div key={i} className={r.cls}>{r.t}</div>))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.kind === "welcome") {
|
||||
return (
|
||||
<div key={l.id} className="term-card" style={{ borderColor: "var(--accent-line)" }}>
|
||||
<div style={{ color: "var(--accent)", fontWeight: 600 }}>◇ agent session</div>
|
||||
<div className="dim" style={{ color: "var(--fg-2)", marginTop: 3 }}>{l.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div key={l.id} className={"tline " + (l.cls || "")} dangerouslySetInnerHTML={{ __html: l.html }} />;
|
||||
})}
|
||||
{busy && <div className="tline dim"><span className="ag" style={{ color: "#c98bdb" }}>●</span> working<span className="cursor-blink" /></div>}
|
||||
{!busy && (
|
||||
<div className="term-input">
|
||||
<span className={"ip" + (live ? " ag" : "")}>{live ? ">" : <span className="pfx">console <span style={{ color: "var(--fg-3)" }}>%</span></span>}</span>
|
||||
<textarea ref={inputRef} className="term-ta" value={input} spellCheck={false} autoComplete="off"
|
||||
rows={Math.min(8, Math.max(1, input.split("\n").length))}
|
||||
placeholder={live ? "Ask the agent to change something…" : kind === "agent" ? "type 'claude' to start a session" : ""}
|
||||
onChange={(e) => setInput(e.target.value)} onKeyDown={onKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function bootAgent() {
|
||||
return [
|
||||
{ kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
|
||||
{ kind: "t", cls: "dim", html: " I can read, search and edit files in this project. Describe a change to get started." },
|
||||
];
|
||||
}
|
||||
|
||||
function agentSeed() {
|
||||
return {
|
||||
mode: "agent",
|
||||
lines: [
|
||||
{ id: lid(), kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> claude` },
|
||||
{ id: lid(), kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
|
||||
{ id: lid(), kind: "t", html: `<span class="ip ag">></span> add currency + name to the user payload and gate the fillable fields` },
|
||||
{ id: lid(), kind: "t", html: `<span class="ag">●</span> Read <span class="fp">UserController.php</span>, edited the response array and <span class="fp">onlyFillable()</span>.` },
|
||||
{
|
||||
id: lid(), kind: "card", ch: "edit · src/Http/Controller/UserController.php",
|
||||
rows: [
|
||||
{ cls: "del", t: "- 'plan' => $user->plan," },
|
||||
{ cls: "add", t: "+ 'plan' => $user->plan->value," },
|
||||
{ cls: "add", t: "+ 'name' => $user->name," },
|
||||
],
|
||||
},
|
||||
{ id: lid(), kind: "t", cls: "ok", html: `<span class="ag">●</span> <span class="ok">Done</span> — updated <span class="fp">UserController.php</span>. Anything else?` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function shellSeed() {
|
||||
return {
|
||||
mode: "shell",
|
||||
lines: [
|
||||
{ id: lid(), kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> git status -s` },
|
||||
...PROJECT.changes.map((c) => ({
|
||||
id: lid(), kind: "t",
|
||||
html: `<span style="color:var(--${c.status === 'A' ? 'add' : c.status === 'D' ? 'del' : 'mod'})">${c.status} </span> <span class="fp">${c.path}</span>`,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Object.assign(window, { Terminal, agentSeed, shellSeed });
|
||||
372
design_handoff_helder_workbench/design/styles.css
Normal file
372
design_handoff_helder_workbench/design/styles.css
Normal file
@@ -0,0 +1,372 @@
|
||||
/* ============ Agentic Coding Panel — dark, charcoal-neutral ============ */
|
||||
:root {
|
||||
--bg-0:#16171a; /* editor surface (deepest) */
|
||||
--bg-1:#1a1c1f; /* terminals */
|
||||
--bg-2:#1f2226; /* sidebars */
|
||||
--bg-3:#23262b; /* headers / tabs strip */
|
||||
--hover:#2a2e34;
|
||||
--active:#313742;
|
||||
--sel:#2b323d;
|
||||
--border:#2a2d33;
|
||||
--border-2:#34383f;
|
||||
--fg-0:#e6e8ea;
|
||||
--fg-1:#b4bac2;
|
||||
--fg-2:#838a94;
|
||||
--fg-3:#5d636c;
|
||||
--accent:#4d8dff;
|
||||
--accent-soft:rgba(77,141,255,0.16);
|
||||
--accent-line:rgba(77,141,255,0.55);
|
||||
--add:#5cbd6b;
|
||||
--del:#e0696a;
|
||||
--mod:#d8a85c;
|
||||
--ren:#5aa6d6;
|
||||
--add-bg:rgba(92,189,107,0.10);
|
||||
--del-bg:rgba(224,105,106,0.10);
|
||||
/* syntax */
|
||||
--t-key:#c98bdb;
|
||||
--t-str:#94c980;
|
||||
--t-num:#e0a06a;
|
||||
--t-fn:#6aa6f0;
|
||||
--t-com:#5f656e;
|
||||
--t-tag:#7fc6a0;
|
||||
--t-attr:#d8b15c;
|
||||
--t-punc:#9aa0a8;
|
||||
--t-var:#e6e8ea;
|
||||
--t-const:#e08b6a;
|
||||
--t-prop:#6ec0c0;
|
||||
--ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
}
|
||||
|
||||
* { box-sizing:border-box; }
|
||||
html,body { margin:0; height:100%; }
|
||||
body {
|
||||
background:var(--bg-0); color:var(--fg-0);
|
||||
font-family:var(--ui); font-size:13px;
|
||||
overflow:hidden; -webkit-font-smoothing:antialiased;
|
||||
}
|
||||
#root { height:100vh; }
|
||||
::selection { background:rgba(77,141,255,0.32); }
|
||||
|
||||
/* scrollbars */
|
||||
::-webkit-scrollbar { width:11px; height:11px; }
|
||||
::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background:#4a505a; background-clip:content-box; }
|
||||
::-webkit-scrollbar-corner { background:transparent; }
|
||||
|
||||
/* ============ shell ============ */
|
||||
.app { display:flex; flex-direction:column; height:100vh; }
|
||||
|
||||
.titlebar {
|
||||
height:36px; flex:0 0 36px; display:flex; align-items:center;
|
||||
background:var(--bg-3); border-bottom:1px solid var(--border);
|
||||
padding:0 12px; gap:14px; user-select:none;
|
||||
}
|
||||
.traffic { display:flex; gap:8px; }
|
||||
.traffic i { width:12px; height:12px; border-radius:50%; display:block; }
|
||||
.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;}
|
||||
.tb-title { font-size:12px; color:var(--fg-1); display:flex; align-items:center; gap:7px; }
|
||||
.tb-title b { color:var(--fg-0); font-weight:600; }
|
||||
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); }
|
||||
.tb-crumb .seg{color:var(--fg-2);}
|
||||
.tb-spacer { flex:1; }
|
||||
.tb-actions { display:flex; gap:6px; align-items:center; }
|
||||
.tb-btn {
|
||||
font-size:11.5px; color:var(--fg-2); background:transparent; border:1px solid transparent;
|
||||
border-radius:6px; padding:4px 9px; cursor:pointer; display:flex; align-items:center; gap:6px;
|
||||
}
|
||||
.tb-btn:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.tb-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
||||
|
||||
.workbench { flex:1; display:flex; min-height:0; }
|
||||
|
||||
.col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); }
|
||||
.col.editor-col { flex:1; background:var(--bg-0); min-width:240px; }
|
||||
.col.right-col { background:var(--bg-1); }
|
||||
|
||||
.splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; }
|
||||
.splitter::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; }
|
||||
.splitter:hover::after, .splitter.drag::after { background:var(--accent-line); }
|
||||
.splitter.h { cursor:row-resize; flex:0 0 5px; width:100%; }
|
||||
|
||||
/* panel header */
|
||||
.phead {
|
||||
height:30px; flex:0 0 30px; display:flex; align-items:center; gap:8px;
|
||||
padding:0 10px 0 12px; font-size:10.5px; letter-spacing:.09em; text-transform:uppercase;
|
||||
color:var(--fg-2); border-bottom:1px solid var(--border); user-select:none;
|
||||
}
|
||||
.phead .ct { margin-left:auto; font-size:10px; color:var(--fg-3); letter-spacing:.02em; text-transform:none;
|
||||
background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:16px; }
|
||||
.phead .ico-btn { color:var(--fg-3); cursor:pointer; padding:2px; border-radius:4px; display:flex; }
|
||||
.phead .ico-btn:hover { background:var(--hover); color:var(--fg-1); }
|
||||
|
||||
/* ============ git panel ============ */
|
||||
.commit-box { padding:9px 10px; border-bottom:1px solid var(--border); display:flex; gap:7px; align-items:flex-start; }
|
||||
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
|
||||
.commit-input:focus { border-color:var(--accent-line); }
|
||||
.commit-input::placeholder { color:var(--fg-3); }
|
||||
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#0c1320; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
|
||||
.commit-btn:hover:not(:disabled) { background:#5d97ff; }
|
||||
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
|
||||
.git-body { overflow:auto; flex:1; padding:4px 0 10px; }
|
||||
.git-group { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
|
||||
.git-group .gc { color:var(--fg-3); }
|
||||
.git-group .grp-act { margin-left:auto; display:flex; opacity:0; background:transparent; border:0; color:var(--fg-2); padding:2px; border-radius:4px; cursor:pointer; }
|
||||
.git-group:hover .grp-act { opacity:1; }
|
||||
.git-group .grp-act:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.git-empty { display:flex; flex-direction:column; align-items:center; gap:9px; padding:30px 16px; color:var(--fg-3); font-size:12px; text-align:center; }
|
||||
.git-empty svg { color:var(--add); opacity:.7; }
|
||||
.git-none { padding:5px 14px 9px; font-size:11.5px; color:var(--fg-3); }
|
||||
.git-none .key { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
||||
.git-divider { height:1px; background:var(--border); margin:8px 12px 2px; }
|
||||
.git-row {
|
||||
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
|
||||
}
|
||||
.git-row:hover { background:var(--hover); }
|
||||
.git-row.active { background:var(--sel); }
|
||||
.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
||||
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
|
||||
.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
|
||||
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.git-row.active .git-name { color:var(--fg-0); }
|
||||
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
|
||||
.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
|
||||
.git-act { flex:0 0 auto; display:none; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
|
||||
.git-row:hover .git-act { display:flex; }
|
||||
.git-act:hover { background:var(--active); color:var(--fg-0); }
|
||||
.git-delta { font-family:var(--mono); font-size:10.5px; display:flex; gap:6px; flex:0 0 auto; }
|
||||
.git-delta .a{color:var(--add);} .git-delta .d{color:var(--del);}
|
||||
.git-foot { border-top:1px solid var(--border); padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--fg-2); }
|
||||
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
|
||||
.branch-chip b { font-weight:600; color:var(--fg-0); }
|
||||
|
||||
/* ============ file tree ============ */
|
||||
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
|
||||
.tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; }
|
||||
.tree-row:hover { background:var(--hover); }
|
||||
.tree-row.active { background:var(--sel); }
|
||||
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
||||
.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; }
|
||||
.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
|
||||
.tree-row.active .tree-label { color:var(--fg-0); }
|
||||
.tree-row.folder .tree-label { color:var(--fg-1); }
|
||||
.tree-badge { margin-left:auto; font-family:var(--mono); font-size:10px; font-weight:600; padding-left:8px; }
|
||||
.tree-badge.M{color:var(--mod);} .tree-badge.A{color:var(--add);} .tree-badge.D{color:var(--del);}
|
||||
|
||||
/* file type monogram icon */
|
||||
.ficon { width:15px; height:15px; flex:0 0 15px; border-radius:3.5px; display:inline-flex; align-items:center; justify-content:center;
|
||||
font-family:var(--mono); font-size:7.5px; font-weight:700; color:#11131600; position:relative; }
|
||||
.ficon span { color:#0c0d0f; font-size:7.5px; line-height:1; letter-spacing:-.3px; }
|
||||
.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
|
||||
|
||||
/* ============ editor ============ */
|
||||
.tabs { height:35px; flex:0 0 35px; display:flex; align-items:stretch; background:var(--bg-3); border-bottom:1px solid var(--border); overflow-x:auto; overflow-y:hidden; }
|
||||
.tabs::-webkit-scrollbar { height:0; }
|
||||
.tab {
|
||||
display:flex; align-items:center; gap:7px; padding:0 9px 0 13px; cursor:pointer;
|
||||
border-right:1px solid var(--border); color:var(--fg-2); font-size:12.5px; white-space:nowrap;
|
||||
background:var(--bg-3); position:relative; max-width:230px;
|
||||
}
|
||||
.tab:hover { background:#272b31; }
|
||||
.tab.active { background:var(--bg-0); color:var(--fg-0); }
|
||||
.tab.active::after { content:""; position:absolute; left:0; right:0; top:0; height:2px; background:var(--accent); }
|
||||
.tab .tname { overflow:hidden; text-overflow:ellipsis; }
|
||||
.tab.dirty .tname::after { content:" ●"; color:var(--mod); font-size:10px; }
|
||||
.tab .tclose { width:17px; height:17px; border-radius:4px; display:flex; align-items:center; justify-content:center; color:var(--fg-3); flex:0 0 17px; }
|
||||
.tab .tclose:hover { background:var(--active); color:var(--fg-0); }
|
||||
.tab .tdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--fg-2); }
|
||||
.tab.dirtyclose .tclose { display:none; }
|
||||
.tab.dirtyclose:hover .tclose { display:flex; }
|
||||
.tab.dirtyclose:hover .tdot { display:none; }
|
||||
.tab.dirtyclose .tdot { display:block; }
|
||||
.tab-mode { margin-left:6px; font-size:9.5px; letter-spacing:.05em; text-transform:uppercase; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; line-height:14px; }
|
||||
|
||||
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
|
||||
.editor { flex:1; overflow:auto; font-family:var(--mono); font-size:13px; line-height:20px; padding:6px 0 40px; }
|
||||
.ln-row { display:flex; align-items:flex-start; min-height:20px; }
|
||||
.ln-row.cursor { background:rgba(255,255,255,0.035); }
|
||||
.ln-row.add { background:var(--add-bg); }
|
||||
.ln-row.del { background:var(--del-bg); }
|
||||
.ln-row.selrange { background:var(--accent-soft); }
|
||||
.ln-gutter { flex:0 0 54px; width:54px; text-align:right; padding-right:14px; color:var(--fg-3); user-select:none; cursor:pointer; font-size:12px; }
|
||||
.ln-row.cursor .ln-gutter { color:var(--fg-1); }
|
||||
.ln-gutter:hover { color:var(--fg-1); }
|
||||
.ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); }
|
||||
.ln-row.add .ln-sign { color:var(--add); }
|
||||
.ln-row.del .ln-sign { color:var(--del); }
|
||||
.ln-code { flex:1; white-space:pre; padding:0 16px 0 6px; min-width:0; }
|
||||
.editor.diff .ln-code { padding-left:6px; }
|
||||
.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:14px; }
|
||||
.empty-ed .big { font-size:13px; }
|
||||
.empty-ed kbd { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
|
||||
.empty-ed .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; }
|
||||
.empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
|
||||
|
||||
.diff-bar { height:26px; flex:0 0 26px; display:flex; align-items:center; gap:12px; padding:0 14px; background:var(--bg-3); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-2); }
|
||||
.diff-bar .a{color:var(--add);font-family:var(--mono);} .diff-bar .d{color:var(--del);font-family:var(--mono);}
|
||||
.diff-bar .toggle { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:6px; overflow:hidden; }
|
||||
.diff-bar .toggle button { background:transparent; border:0; color:var(--fg-2); font:inherit; font-size:11px; padding:2px 10px; cursor:pointer; }
|
||||
.diff-bar .toggle button.on { background:var(--accent-soft); color:var(--fg-0); }
|
||||
|
||||
/* four-segment view control */
|
||||
.diff-bar .seg { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:7px; overflow:hidden; }
|
||||
.diff-bar .seg button { background:transparent; border:0; border-right:1px solid var(--border-2); color:var(--fg-2); font:inherit; font-size:11px; padding:3px 12px; cursor:pointer; display:flex; align-items:center; gap:6px; }
|
||||
.diff-bar .seg button:last-child { border-right:0; }
|
||||
.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
|
||||
.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
|
||||
.diff-bar .seg .split-btn svg { opacity:.85; }
|
||||
|
||||
/* gutter change bars (Original / Updated / Split) */
|
||||
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
|
||||
.ln-row.bar-add { box-shadow:inset 2px 0 0 var(--add); }
|
||||
.ln-row.empty { background:repeating-linear-gradient(45deg, rgba(255,255,255,0.015) 0 7px, transparent 7px 14px); }
|
||||
|
||||
/* full-screen split */
|
||||
.split-overlay { position:fixed; inset:0; z-index:60; background:var(--bg-0); display:flex; flex-direction:column; animation:tin .12s ease-out; }
|
||||
.split-head { height:42px; flex:0 0 42px; display:flex; align-items:center; gap:11px; padding:0 16px; background:var(--bg-3); border-bottom:1px solid var(--border); }
|
||||
.split-head .sh-name { font-family:var(--mono); font-size:13px; color:var(--fg-0); }
|
||||
.split-head .git-stat { font-size:11px; }
|
||||
.split-exit { margin-left:auto; display:flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--border-2); border-radius:7px; color:var(--fg-1); font:inherit; font-size:12px; padding:5px 11px; cursor:pointer; }
|
||||
.split-exit:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.split-exit kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
||||
.split-body { flex:1; display:flex; min-height:0; }
|
||||
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
|
||||
.split-pane.left { border-right:1px solid var(--border-2); }
|
||||
.split-label { height:27px; flex:0 0 27px; display:flex; align-items:center; gap:9px; padding:0 16px; font-size:10.5px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-2); background:var(--bg-2); border-bottom:1px solid var(--border); }
|
||||
.split-label span { text-transform:none; letter-spacing:0; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
|
||||
|
||||
/* syntax token colors */
|
||||
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important{color:var(--t-key);}
|
||||
.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex{color:var(--t-str);}
|
||||
.ln-code .token.number,.ln-code .token.unit{color:var(--t-num);}
|
||||
.ln-code .token.function,.ln-code .token.method{color:var(--t-fn);}
|
||||
.ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata{color:var(--t-com);font-style:italic;}
|
||||
.ln-code .token.tag{color:var(--t-tag);}
|
||||
.ln-code .token.attr-name{color:var(--t-attr);}
|
||||
.ln-code .token.punctuation{color:var(--t-punc);}
|
||||
.ln-code .token.operator{color:var(--t-punc);}
|
||||
.ln-code .token.variable,.ln-code .token.symbol{color:var(--t-var);}
|
||||
.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin{color:var(--t-const);}
|
||||
.ln-code .token.property,.ln-code .token.property-access{color:var(--t-prop);}
|
||||
.ln-code .token.class-name,.ln-code .token.maybe-class-name{color:var(--t-attr);}
|
||||
.ln-code .token.parameter{color:var(--fg-0);}
|
||||
.ln-code .token.namespace{color:var(--fg-2);}
|
||||
.ln-code .token.selector{color:var(--t-tag);}
|
||||
.ln-code .token.entity,.ln-code .token.url{color:var(--t-prop);}
|
||||
.ln-code .token.deleted{color:var(--del);} .ln-code .token.inserted{color:var(--add);}
|
||||
|
||||
/* ============ terminals (right column) ============ */
|
||||
.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); }
|
||||
.term-head { height:28px; flex:0 0 28px; display:flex; align-items:center; gap:8px; padding:0 10px; background:var(--bg-3); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
|
||||
.term-head .dot { width:7px; height:7px; border-radius:50%; background:var(--fg-3); }
|
||||
.term-head .dot.live { background:var(--add); box-shadow:0 0 0 0 rgba(92,189,107,.5); animation:pulse 2.2s infinite; }
|
||||
@keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(92,189,107,.45);} 70%{box-shadow:0 0 0 5px rgba(92,189,107,0);} 100%{box-shadow:0 0 0 0 rgba(92,189,107,0);} }
|
||||
.term-head .lbl { color:var(--fg-1); font-family:var(--mono); }
|
||||
.term-head .tag { margin-left:auto; font-size:10px; color:var(--fg-3); font-family:var(--mono); }
|
||||
.term-body { flex:1; overflow:auto; padding:8px 12px 12px; font-family:var(--mono); font-size:12.5px; line-height:18px; cursor:text; }
|
||||
.tline { white-space:pre-wrap; word-break:break-word; }
|
||||
.tline.dim{color:var(--fg-3);} .tline.acc{color:var(--accent);} .tline.ok{color:var(--add);} .tline.warn{color:var(--mod);} .tline.err{color:var(--del);}
|
||||
.tline .pfx { color:var(--accent); }
|
||||
.tline .ag { color:#c98bdb; }
|
||||
.tline .tool { color:var(--mod); }
|
||||
.tline .fp { color:var(--t-prop); }
|
||||
.term-card { border:1px solid var(--border-2); border-radius:7px; padding:7px 10px; margin:5px 0; background:rgba(255,255,255,0.02); }
|
||||
.term-card .ch { color:var(--fg-2); font-size:11px; margin-bottom:4px; display:flex; gap:7px; align-items:center; min-width:0; }
|
||||
.term-card .ch span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.term-card .add,.term-card .del { white-space:pre-wrap; word-break:break-word; line-height:17px; }
|
||||
.term-card .add{color:var(--add);} .term-card .del{color:var(--del);}
|
||||
.term-input { display:flex; align-items:center; gap:8px; }
|
||||
.term-input .ip { color:var(--accent); }
|
||||
.term-input .ip.ag { color:#c98bdb; }
|
||||
.term-input input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-family:var(--mono); font-size:12.5px; caret-color:var(--accent); }
|
||||
.cursor-blink { display:inline-block; width:7px; height:14px; background:var(--accent); margin-left:1px; animation:blink 1.1s step-end infinite; vertical-align:-2px; }
|
||||
@keyframes blink { 50%{opacity:0;} }
|
||||
|
||||
/* ============ overlays ============ */
|
||||
.scrim { position:fixed; inset:0; background:rgba(8,9,11,0.5); z-index:50; display:flex; justify-content:center; align-items:flex-start; padding-top:90px; backdrop-filter:blur(1.5px); }
|
||||
.palette { width:620px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; }
|
||||
.palette .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
|
||||
.palette .pi input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); }
|
||||
.palette .pi .mode-chip { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); }
|
||||
.palette .results { max-height:380px; overflow:auto; padding:6px; }
|
||||
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
|
||||
.pres.sel { background:var(--accent-soft); }
|
||||
.pres .pn { font-size:13px; color:var(--fg-0); }
|
||||
.pres .pn b { color:var(--accent); font-weight:700; }
|
||||
.pres .pp { font-size:11px; color:var(--fg-3); margin-left:auto; font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:55%; direction:rtl; }
|
||||
.pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; }
|
||||
|
||||
/* combined search modal (content + files) */
|
||||
.search-modal { width:940px; max-width:94vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
|
||||
.search-cols { display:flex; min-height:0; }
|
||||
.sc-left { flex:1 1 auto; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; }
|
||||
.sc-right { flex:0 0 256px; min-width:0; max-height:460px; overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); }
|
||||
.sc-head { position:sticky; top:0; z-index:1; background:#212429; padding:9px 14px 6px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:7px; }
|
||||
.sc-right .sc-head { background:#1e2024; }
|
||||
.sc-head .sc-ct { color:var(--fg-2); background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:15px; font-size:10px; }
|
||||
.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
|
||||
.fres { display:flex; align-items:center; gap:9px; padding:6px 12px; cursor:pointer; }
|
||||
.fres:hover { background:var(--hover); }
|
||||
.fres-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
|
||||
.fres-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.fres-txt .fn b { color:var(--accent); font-weight:700; }
|
||||
.fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
/* content search */
|
||||
.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; }
|
||||
.sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; }
|
||||
.sr-file:hover { color:var(--fg-0); }
|
||||
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
|
||||
.sr-line { display:flex; gap:12px; padding:2px 14px 2px 38px; font-family:var(--mono); font-size:12px; cursor:pointer; color:var(--fg-1); }
|
||||
.sr-line:hover { background:var(--hover); }
|
||||
.sr-line .no { color:var(--fg-3); min-width:34px; text-align:right; }
|
||||
.sr-line .tx { white-space:pre; overflow:hidden; text-overflow:ellipsis; }
|
||||
.sr-line mark { background:rgba(216,168,92,.28); color:var(--fg-0); border-radius:2px; }
|
||||
|
||||
/* pass-on-to-agent inline popup */
|
||||
.pass-pop { position:fixed; z-index:85; width:344px; max-width:92vw; background:#23272d; border:1px solid var(--border-2); border-radius:10px; box-shadow:0 18px 48px rgba(0,0,0,.55); padding:11px; animation:popin .12s ease-out; }
|
||||
@keyframes popin { from { transform:translateY(6px); } }
|
||||
.pass-head { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--fg-1); margin-bottom:9px; }
|
||||
.pass-head svg { color:#c98bdb; }
|
||||
.pass-head .pass-esc { margin-left:auto; font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 6px; }
|
||||
.pass-input { width:100%; box-sizing:border-box; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:13px; padding:8px 10px; outline:none; }
|
||||
.pass-input:focus { border-color:var(--accent-line); }
|
||||
.pass-input::placeholder { color:var(--fg-3); }
|
||||
.pass-preview { margin-top:9px; display:flex; align-items:center; gap:8px; min-width:0; }
|
||||
.pass-preview .pp-lbl { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--fg-3); flex:0 0 auto; }
|
||||
.pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); }
|
||||
.pass-foot kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
||||
|
||||
/* terminal multi-line input */
|
||||
.term-input { align-items:flex-start; }
|
||||
.term-ta { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-family:var(--mono); font-size:12.5px; line-height:18px; caret-color:var(--accent); resize:none; padding:0; margin:0; overflow:hidden; }
|
||||
.term-ta::placeholder { color:var(--fg-3); }
|
||||
|
||||
/* context menu */.ctx { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); }
|
||||
.ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); }
|
||||
.ctx-item:hover { background:var(--accent-soft); color:var(--fg-0); }
|
||||
.ctx-item .kc { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
|
||||
.ctx-item.primary { color:var(--fg-0); }
|
||||
.ctx-item.primary .ic { color:var(--accent); }
|
||||
.ctx-item .ic { width:15px; display:flex; justify-content:center; color:var(--fg-3); }
|
||||
.ctx-sep { height:1px; background:var(--border); margin:5px 6px; }
|
||||
.ctx-note { padding:4px 11px 7px; font-size:10.5px; color:var(--fg-3); font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
/* toast */
|
||||
.toast-wrap { position:fixed; bottom:34px; left:50%; transform:translateX(-50%); z-index:90; display:flex; flex-direction:column; gap:8px; align-items:center; }
|
||||
.toast { background:#23272d; border:1px solid var(--border-2); border-left:3px solid var(--accent); border-radius:9px; padding:9px 14px; box-shadow:0 12px 34px rgba(0,0,0,.45); display:flex; align-items:center; gap:11px; animation:tin .18s ease-out; }
|
||||
@keyframes tin { from{opacity:0; transform:translateY(8px);} }
|
||||
.toast .tt { font-size:12.5px; color:var(--fg-0); }
|
||||
.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; }
|
||||
|
||||
/* ============ status bar ============ */
|
||||
.statusbar { height:23px; flex:0 0 23px; display:flex; align-items:center; gap:0; background:var(--bg-3); border-top:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
|
||||
.sb { display:flex; align-items:center; gap:6px; padding:0 11px; height:100%; }
|
||||
.sb:hover { background:var(--hover); }
|
||||
.sb.accent { background:var(--accent); color:#0c1320; }
|
||||
.sb.accent:hover { background:#5d97ff; }
|
||||
.sb.spacer { flex:1; }
|
||||
.sb .a{color:var(--add);} .sb .d{color:var(--del);}
|
||||
.sb b { font-weight:600; color:var(--fg-1); }
|
||||
Reference in New Issue
Block a user