wave 1 refactor cont.

This commit is contained in:
2026-05-08 00:42:34 -04:00
parent d187856474
commit 89d3abad8a
6 changed files with 203 additions and 360 deletions
+5
View File
@@ -41,6 +41,11 @@ AGENTS.md
CLAUDE.md
GEMINI.md
# Local / AI tracking — markdown and docs tree (not shipping code)
/docs/
/*.md
!/README.md
# One-off docs not part of the project
MIGRATION_FIXES.md
SONARQUBE_GITEA_SETUP.md
@@ -0,0 +1,121 @@
//! Line endings, image detection, and view kind for open-file flows.
use std::path::Path;
use serde::Serialize;
use crate::document::language::LanguageId;
use crate::editor::ViewKind;
/// Line ending style inferred from buffer content (same rules as the TS helper).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum LineEnding {
#[serde(rename = "LF")]
Lf,
#[serde(rename = "CRLF")]
Crlf,
#[serde(rename = "CR")]
Cr,
}
#[must_use]
pub fn detect_line_ending(content: &str) -> LineEnding {
if content.contains("\r\n") {
LineEnding::Crlf
} else if content.contains('\r') {
LineEnding::Cr
} else {
LineEnding::Lf
}
}
#[must_use]
pub fn is_image_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| {
matches!(
e.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "ico" | "bmp"
)
})
}
#[must_use]
pub fn image_mime_for_path(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
_ => return None,
})
}
#[must_use]
pub fn derive_view_kind(path: &Path, language: LanguageId) -> ViewKind {
if is_image_path(path) {
ViewKind::Image
} else if language == LanguageId::Markdown {
ViewKind::MarkdownPreviewOnly
} else {
ViewKind::Text
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn detect_line_ending_crlf() {
assert_eq!(detect_line_ending("a\r\nb"), LineEnding::Crlf);
}
#[test]
fn detect_line_ending_cr_only() {
assert_eq!(detect_line_ending("a\rb"), LineEnding::Cr);
}
#[test]
fn detect_line_ending_lf() {
assert_eq!(detect_line_ending("a\nb"), LineEnding::Lf);
}
#[test]
fn image_mime_png() {
assert_eq!(
image_mime_for_path(Path::new("/x.PNG")),
Some("image/png")
);
}
#[test]
fn derive_view_kind_image_wins_over_markdown_name() {
let p = PathBuf::from("/tmp/readme.png");
assert_eq!(
derive_view_kind(&p, LanguageId::Markdown),
ViewKind::Image
);
}
#[test]
fn derive_view_kind_markdown() {
let p = PathBuf::from("/tmp/a.md");
assert_eq!(
derive_view_kind(&p, LanguageId::Markdown),
ViewKind::MarkdownPreviewOnly
);
}
#[test]
fn derive_view_kind_plain_text() {
let p = PathBuf::from("/tmp/a.rs");
assert_eq!(derive_view_kind(&p, LanguageId::Rust), ViewKind::Text);
}
}
+25
View File
@@ -0,0 +1,25 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RustBridgeCmd = void 0;
/**
* Rust JSON-bridge `cmd` field values (snake_case).
* Keep in sync with `ui/src/bridge/commands.ts`.
*/
exports.RustBridgeCmd = {
OpenFile: 'open_file',
RevertFile: 'revert_file',
SaveFile: 'save_file',
ReadFileWithEncoding: 'read_file_with_encoding',
ReadBinary: 'read_binary',
LoadSession: 'load_session',
SaveSession: 'save_session',
UpdateTabContent: 'update_tab_content',
ReadDirectory: 'read_directory',
ListWorkspaceFiles: 'list_workspace_files',
DetectLanguage: 'detect_language',
LintDocument: 'lint_document',
FormatDocument: 'format_document',
DetectEncoding: 'detect_encoding',
ListLanguages: 'list_languages',
ListEncodings: 'list_encodings',
};
-360
View File
@@ -1,360 +0,0 @@
# ByteDraft — Electron Migration Plan
## Current status
```
[✓] Phase 0 Decision & planning
[✓] Phase 1 Electron shell + contextBridge
[✓] Phase 2 Rust subprocess JSON bridge
[✓] Phase 3 Replace @tauri-apps/api in React
[✓] Phase 4 Tear-away windows
[✓] Phase 5 E2E test migration
[✓] Phase 6 CI pipeline update
```
Update the `[✓]` / `[ ]` markers as each phase completes.
---
## Target architecture
```
React 19 + Vite (ui/)
↓ window.api.* (contextBridge — electron/preload.ts)
Electron main (electron/main.ts)
↓ JSON subprocess
byte_draft_desktop binary (crates/byte_draft_desktop/ — thin CLI wrapper)
↓ Rust crate
byte_draft core (crates/byte_draft/ — encoding, language, format, lint)
```
Key decisions:
- **Rust bridge**: JSON subprocess (stdin/stdout), NOT napi-rs
- **Packaging**: Electron Forge
- **E2E**: Native `@playwright/test` with `_electron` fixture — no bridge plugin
---
## Repo layout
```
crates/byte_draft/ Rust core library — KEEP AS-IS
crates/byte_draft_desktop/ Currently Tauri shell → becomes CLI subprocess target
ui/ React 19 + Vite — KEEP AS-IS except Tauri imports
ui/src/bindings.ts Tauri Specta auto-gen → DELETE, replaced by ui/src/api.ts
e2e/ Playwright tests → migrate fixture, keep specs
electron/ NEW — main.ts, preload.ts (create in Phase 1)
```
---
## Phase 1 — Electron shell + contextBridge
**Goal**: App launches in Electron with window chrome, vibrancy, and all 14 IPC stubs returning
mock data. React renders without import errors. Vitest passes unchanged.
### Files to create
| File | Purpose |
|------|---------|
| `electron/main.ts` | BrowserWindow config, ipcMain stubs for all 14 commands |
| `electron/preload.ts` | contextBridge exposing `window.api` |
| `electron/tsconfig.json` | CJS target for Electron main process |
| `package.json` (root) | Electron Forge config + `electron:dev` / `electron:build` scripts |
### Files to modify
| File | Change |
|------|--------|
| `ui/package.json` | Remove `@tauri-apps/*`, `@srsholmes/tauri-playwright`; add `electron`, `@electron-forge/cli` |
| `ui/vite.config.ts` | Add `base: './'` so assets resolve from `file://` in production |
### window.api surface (preload.ts)
```typescript
window.api = {
// file ops
openFile(path: string): Promise<FileData>
saveFile(path: string, content: string, encoding: string): Promise<void>
saveFileAs(content: string): Promise<string | null>
revertFile(path: string): Promise<FileData>
readFileWithEncoding(path: string, encoding: string): Promise<FileData>
readBinary(path: string): Promise<number[]>
// session
loadSession(): Promise<string | null>
saveSession(json: string): Promise<void>
updateTabContent(tabId: string, content: string): Promise<void>
// workspace
openFolder(): Promise<string | null>
readDirectory(path: string): Promise<DirEntry[]>
listWorkspaceFiles(root: string): Promise<string[]>
// editor
detectLanguage(path: string, content: string): Promise<string>
lintDocument(content: string, lang: string): Promise<Diagnostic[]>
formatDocument(content: string, lang: string): Promise<string>
detectEncoding(path: string): Promise<string>
// window controls
minimize(): void
toggleMaximize(): void
close(): void
showMessageBox(opts: { title: string; message: string; kind: 'info'|'warning'|'error' }): Promise<void>
showOpenDialog(opts: { title?: string; multiple?: boolean }): Promise<string | null>
// drag-drop
onFilesDropped(cb: (paths: string[]) => void): () => void
}
```
### Phase 1 exit criteria
- `npm run electron:dev` (from repo root) opens ByteDraft in an Electron window
- macOS: vibrancy visible; Windows: custom title bar visible
- All IPC stubs return empty/null without throwing
- `npm test` (Vitest) passes with zero changes to test files
- No `@tauri-apps/*` imports remain in `ui/src/`
---
## Phase 2 — Rust subprocess JSON bridge
**Goal**: All 14 commands route to the real Rust backend. File open/save/format work end-to-end.
### Files to change
| File | Change |
|------|--------|
| `crates/byte_draft_desktop/src/main.rs` | Add `--cli` flag: read newline-delimited JSON from stdin, dispatch to handler fns, write JSON to stdout |
| `electron/bridge.ts` | NEW — spawn binary, maintain stdin/stdout streams, queue commands, return promises |
### JSON protocol (newline-delimited, one request + one response per line)
```jsonc
// Request
{ "id": "uuid", "cmd": "open_file", "args": { "path": "/foo/bar.txt" } }
// Response success
{ "id": "uuid", "ok": { "content": "…", "encoding": "UTF-8", "path": "/foo/bar.txt" } }
// Response error
{ "id": "uuid", "err": "file not found" }
```
### Phase 2 exit criteria
- Open a real file from disk, edit it, save it — all via Electron + Rust subprocess
- Format document (JSON/YAML) calls Rust and returns formatted text
- Session persists across app restarts
---
## Phase 3 — Replace @tauri-apps/api in React
**Goal**: All Tauri imports gone from `ui/src/`. App works in browser dev mode and Electron.
### Files to change (exhaustive)
| File | What to change |
|------|---------------|
| `ui/src/bindings.ts` | **Delete** — replace with `ui/src/api.ts` typed wrapper around `window.api` |
| `ui/src/App.tsx` | Replace `webviewWindow.onDragDropEvent` with `window.api.onFilesDropped` |
| `ui/src/hooks/useFileSystem.ts` | Replace `invoke` + `dialogOpen`/`dialogMessage` with `window.api.*` |
| `ui/src/lib/formatDocument.ts` | Replace `invoke` with `window.api.formatDocument` |
| `ui/src/components/Editor/Editor.tsx` | Replace `invoke` + `dialogMessage` with `window.api.*` |
| `ui/src/state/sessionStore.ts` | Replace `invoke` with `window.api.loadSession`/`saveSession` |
| `ui/src/components/ImageView/ImageView.tsx` | Replace `invoke('read_binary')` with `window.api.readBinary` |
| `ui/src/components/FileTree/FileTree.tsx` | Replace `invoke` with `window.api.readDirectory` |
| `ui/src/components/TitleBar/TitleBar.tsx` | Replace `getCurrentWindow()` with `window.api.minimize/toggleMaximize/close`; replace `data-tauri-drag-region` with `data-electron-drag-region` |
| `ui/src/components/StatusBar/StatusBar.tsx` | Replace `invoke` with `window.api.*` |
### New file: `ui/src/api.ts`
```typescript
// Falls back to mock data when window.api is undefined (browser dev / Vitest)
export const api = (window as unknown as { api: WindowApi }).api ?? mockApi;
```
### Phase 3 exit criteria
- `grep -r "@tauri-apps" ui/src/` returns zero results
- App fully functional in Electron (open, edit, save, format, workspace tree)
- `npm test` (Vitest) passes
---
## Phase 4 — Tear-away windows
**Goal**: Dragging a tab below the tab strip creates a new Electron window with that tab's state.
### How it works
1. User drags tab outside threshold → component sends IPC `tear-off-tab` with tab state payload
2. `ipcMain` handles `tear-off-tab`:
- Creates `new BrowserWindow(...)` with same options as main window
- New window loads `index.html?tabId=<id>`; serialised tab state passed via `loadURL` query param or `webContents.send`
3. React in new window: reads `?tabId` from URL, skips session restore, renders just that tab
4. State sync: changes in detached window send IPC back to main; main broadcasts to all windows
### Phase 4 exit criteria
- Drag a tab 100 px below the title bar → new Electron window appears with that file
- Changes in detached window sync back (save writes to correct path)
- `electronApp.windows()` in Playwright returns 2 after tear-off
---
## Phase 5 — E2E test migration
**Goal**: Playwright tests run against real Electron binary with full window visibility.
### Files to change
| File | Change |
|------|--------|
| `e2e/fixtures/tauri-test.ts` | Replace with `electron-test.ts` using `_electron` fixture |
| `e2e/playwright.config.ts` | Remove `tauri` project; add `electron` project; remove `webServer` block |
| `e2e/specs/*.tauri.spec.ts` | Rename to `*.electron.spec.ts`; update fixture import |
### New fixture skeleton (e2e/fixtures/electron-test.ts)
```typescript
import { test as base, _electron as electron, ElectronApplication, Page } from '@playwright/test';
export const test = base.extend<{ app: ElectronApplication; window: Page }>({
app: async ({}, use) => {
const app = await electron.launch({ args: ['dist-electron/main.js'] });
await use(app);
await app.close();
},
window: async ({ app }, use) => {
const win = await app.firstWindow();
await win.waitForLoadState('domcontentloaded');
await use(win);
},
});
export { expect } from '@playwright/test';
```
### New specs to add
- `e2e/specs/tear-away.electron.spec.ts` — drag tab, verify `app.windows().length === 2`
- `e2e/specs/window-controls.electron.spec.ts` — minimize, maximize, close buttons
### Phase 5 exit criteria
- All browser-mode specs pass unchanged
- Electron-mode specs pass (editor behavior, tear-away, window controls)
- `npm run test:e2e` green locally
---
## Phase 6 — CI pipeline update
**Goal**: CI builds Electron app and runs Playwright Electron tests; all Tauri steps removed.
### File: `.gitea/workflows/ci.yml`
| Change | Detail |
|--------|--------|
| Remove `build-tauri` job | Was `cargo tauri build --features e2e-testing` |
| Add `build-electron` job | `npm run make` (Electron Forge) → produces binary artifact |
| Update `e2e` job | Remove `TAURI_APP_PATH`; set `ELECTRON_APP_PATH` to Forge output path |
| Remove `--features e2e-testing` | From all Rust build steps |
| Remove Linux WebKit2 apt deps | `libwebkit2gtk-4.1-dev` etc. no longer needed |
| Keep all Rust jobs | `fmt`, `clippy`, `test``byte_draft` core still tested |
### Phase 6 exit criteria
- CI pipeline green end-to-end on a push to the migration branch
- Electron binary uploaded as CI artifact
- SonarQube gate passes
---
## Feature parity checklist
Check off as verified working in the Electron build.
### Editor Core
- [✓] CodeMirror 6 renders and accepts input
- [✓] Syntax highlighting (Rust, Python, JS/TS, Go, JSON, YAML, TOML, XML, Markdown, Shell, CSS, HTML, C/C++, Java, SQL)
- [✓] Find/Replace with regex
- [✓] Word wrap toggle
- [✓] Format on save
- [✓] Format on paste
- [✓] Line ending detection (CRLF/LF/CR)
- [✓] Cursor position in status bar
- [✓] Language in status bar
- [✓] Encoding in status bar
### File Operations
- [✓] Open file via dialog
- [✓] Open file via workspace tree
- [✓] Save (Ctrl/⌘+S)
- [✓] Save As (Ctrl/⌘+Shift+S)
- [✓] Revert file
- [✓] Encoding detection (encoding_rs via subprocess)
- [✓] Binary read (hex view)
- [✓] Multi-encoding support
### Tab System
- [✓] Multi-tab support
- [✓] Tab reorder (dnd-kit)
- [✓] Close tab (Ctrl/⌘+W)
- [✓] New tab (Ctrl/⌘+T)
- [✓] Cycle tabs (Ctrl/⌘+Tab / Ctrl/⌘+Shift+Tab)
- [✓] Dirty indicator
- [✓] **Tear-away tab → new window**
- [✓] Session restores tabs on relaunch
### Window & Chrome
- [✓] Custom title bar (no native OS bar)
- [✓] Minimize / Maximize / Close buttons
- [✓] Window drag region
- [✓] macOS vibrancy (HUD)
- [✓] Windows: custom chrome
- [✓] Resizable (min 800×600)
### Workspace / Sidebar
- [✓] Open folder
- [✓] File tree with folder expansion
- [✓] File icons
- [✓] Toggle sidebar (Ctrl/⌘+B)
- [✓] Workspace files in command palette
### Special Viewers
- [✓] Markdown live preview (source / split / preview modes)
- [✓] Hex viewer
- [✓] Image viewer
### UI & Theming
- [✓] 6 built-in themes
- [✓] Tweaks panel (Ctrl+K)
- [✓] Accent color picker
- [✓] 3 layout modes (classic, minimal, pane)
- [✓] Command palette (Ctrl/⌘+P)
- [✓] Preferences modal (Ctrl/⌘+,)
- [✓] App (hamburger) menu
### Session & Persistence
- [✓] Save/load session (tabs, workspace, theme)
- [✓] Tab content auto-saved
- [✓] Workspace root remembered
- [✓] Theme remembered
### Backend IPC (15 Rust commands + 1 Electron-native)
- [✓] open_file
- [✓] save_file
- [✓] save_file_as ← dialog handled in Electron main; write routed to save_file in Rust
- [✓] revert_file
- [✓] read_file_with_encoding
- [✓] read_binary
- [✓] load_session
- [✓] save_session
- [✓] update_tab_content
- [✓] open_folder
- [✓] read_directory
- [✓] list_workspace_files
- [✓] detect_language
- [✓] lint_document ← stub (returns empty array; real linting TBD)
- [✓] format_document
- [✓] detect_encoding
### E2E Test Coverage
- [ ] browser-behavior.spec.ts passes
- [ ] browser-ui.spec.ts passes
- [ ] editor-behavior.electron.spec.ts passes
- [ ] tear-away.electron.spec.ts passes
- [ ] window-controls.electron.spec.ts passes
- [ ] CI pipeline green
---
## How to resume in a new session
1. Read `CLAUDE.md` (project root) for a one-page overview
2. Read this file — check the status block at the top for current phase
3. Run `git branch` — work should be on `electron-migration` branch
4. Find the current phase section above; implement what's listed there
5. Verify exit criteria before marking the phase complete and moving on
6. Update the `[ ]``[✓]` marker in the status block when done
+26
View File
@@ -0,0 +1,26 @@
/**
* Rust JSON-bridge `cmd` field values (snake_case).
* Keep in sync with `ui/src/bridge/commands.ts`.
*/
export const RustBridgeCmd = {
OpenFile: 'open_file',
RevertFile: 'revert_file',
SaveFile: 'save_file',
ReadFileWithEncoding: 'read_file_with_encoding',
ReadBinary: 'read_binary',
LoadSession: 'load_session',
SaveSession: 'save_session',
LoadUiPrefs: 'load_ui_prefs',
SaveUiPrefs: 'save_ui_prefs',
UpdateTabContent: 'update_tab_content',
ReadDirectory: 'read_directory',
ListWorkspaceFiles: 'list_workspace_files',
DetectLanguage: 'detect_language',
LintDocument: 'lint_document',
FormatDocument: 'format_document',
DetectEncoding: 'detect_encoding',
ListLanguages: 'list_languages',
ListEncodings: 'list_encodings',
} as const;
export type RustBridgeCmdName = (typeof RustBridgeCmd)[keyof typeof RustBridgeCmd];
+26
View File
@@ -0,0 +1,26 @@
/**
* Rust JSON-bridge `cmd` field values passed to `byteDraft.invoke`.
* Keep in sync with `electron/bridgeCommands.ts`.
*/
export const RustBridgeCmd = {
OpenFile: 'open_file',
RevertFile: 'revert_file',
SaveFile: 'save_file',
ReadFileWithEncoding: 'read_file_with_encoding',
ReadBinary: 'read_binary',
LoadSession: 'load_session',
SaveSession: 'save_session',
LoadUiPrefs: 'load_ui_prefs',
SaveUiPrefs: 'save_ui_prefs',
UpdateTabContent: 'update_tab_content',
ReadDirectory: 'read_directory',
ListWorkspaceFiles: 'list_workspace_files',
DetectLanguage: 'detect_language',
LintDocument: 'lint_document',
FormatDocument: 'format_document',
DetectEncoding: 'detect_encoding',
ListLanguages: 'list_languages',
ListEncodings: 'list_encodings',
} as const;
export type RustBridgeCmdName = (typeof RustBridgeCmd)[keyof typeof RustBridgeCmd];