mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95bbe171b5 | ||
|
|
2d77fa5456 | ||
|
|
fbaff37505 | ||
|
|
1ccde4baa3 | ||
|
|
8822c9a2f9 | ||
|
|
324e5676b8 | ||
|
|
a62511e7ed | ||
|
|
a6c6e3696e | ||
|
|
1b2ab437d1 | ||
|
|
efc7709f66 | ||
|
|
a8f7547c7e | ||
|
|
094b52a2b3 | ||
|
|
24c6675c82 | ||
|
|
929d72cf1d | ||
|
|
b40bff641c | ||
|
|
338669f2db | ||
|
|
54615649f4 | ||
|
|
3ff76c8c33 | ||
|
|
8160ac05e0 | ||
|
|
6762b5f9af | ||
|
|
00485d1bef |
@@ -0,0 +1,250 @@
|
|||||||
|
# Phase 8 — Toasts Design
|
||||||
|
|
||||||
|
> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phases 8+9+10 are sketched at high level; this spec locks Phase 8's architecture, file map, and contracts so it can be planned task-by-task.)
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Phase:** 8 of 10 (React + shadcn/ui UI redesign)
|
||||||
|
**Tag (on completion):** `phase-8-toasts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal & Non-Goals
|
||||||
|
|
||||||
|
**Goal:** Add a toast notification layer to the React renderer using sonner (already installed). Surface user-initiated async action results (file save, file/folder open, exports) as transient toasts in the bottom-right of the window. The error banner pattern in export dialogs stays — toasts are a complementary global channel.
|
||||||
|
|
||||||
|
**Non-goals (Phase 8):**
|
||||||
|
- Undo/redo support (Phase 9)
|
||||||
|
- Custom toast actions/buttons (Phase 9, if needed)
|
||||||
|
- Persistent notification history
|
||||||
|
- Toast queueing/throttling for rapid-fire events
|
||||||
|
- Replacing inline error banners (they stay as per-dialog context)
|
||||||
|
|
||||||
|
**Scope decision:** Toast on user-initiated actions only. Skip silent background operations (loadChildren, etc.) to avoid noise. 4 wire points total: save (success+error), open file/folder (error only), 4 export dialogs (success+error).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
### 2.1 Toast helpers in `lib/toast.ts`
|
||||||
|
|
||||||
|
A thin typed layer over sonner. The helpers exist to:
|
||||||
|
- Give us a single import surface (instead of scattering `import { toast } from 'sonner'` across the codebase)
|
||||||
|
- Provide a place to add brand colors, formatting, or analytics later without touching call sites
|
||||||
|
- Make mocking easier in tests (one module to mock)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/renderer/lib/toast.ts (sketch)
|
||||||
|
import { toast as sonnerToast } from 'sonner';
|
||||||
|
|
||||||
|
export const toast = {
|
||||||
|
success: (message: string) => sonnerToast.success(message),
|
||||||
|
error: (message: string) => sonnerToast.error(message),
|
||||||
|
info: (message: string) => sonnerToast.info(message),
|
||||||
|
warning: (message: string) => sonnerToast.warning(message),
|
||||||
|
promise: <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
msgs: { loading: string; success: string | ((data: T) => string); error: string | ((err: unknown) => string) }
|
||||||
|
) => sonnerToast.promise(promise, msgs),
|
||||||
|
dismiss: (id?: string | number) => sonnerToast.dismiss(id),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 `<Toaster />` mounted in App.tsx
|
||||||
|
|
||||||
|
A canonical shadcn Toaster wrapper (manual paste per memory `shadcn-cli-blocked-manual-primitives`). Mounts alongside `<ModalLayer />`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/renderer/App.tsx (sketch)
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
useWelcomeTrigger();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppShell />
|
||||||
|
<ModalLayer />
|
||||||
|
<Toaster />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Toaster uses sonner's `<Toaster />` component with:
|
||||||
|
- `theme={resolvedTheme}` from `useTheme()` (so toasts match the user's light/dark preference)
|
||||||
|
- `richColors` for semantic success/error/info/warning colors
|
||||||
|
- `position="bottom-right"` (sonner default; explicit for clarity)
|
||||||
|
- `closeButton` (so users can dismiss)
|
||||||
|
|
||||||
|
### 2.3 Inline wiring at 4 wire points
|
||||||
|
|
||||||
|
**Pattern choice — inline in stores/dialogs, NOT middleware.** Matches the Phase 7 precedent of inline error banners in export dialogs. Middleware would be DRY but harder to debug and harder to control granularity (we want errors on save, but NOT on silent loadChildren).
|
||||||
|
|
||||||
|
**Wire points:**
|
||||||
|
|
||||||
|
1. **`useFileStore.saveActiveBuffer`** — wrap the existing logic:
|
||||||
|
```ts
|
||||||
|
saveActiveBuffer: async () => {
|
||||||
|
// ... existing logic to get buffer ...
|
||||||
|
const writeResult = await ipc.file.write(activeTabId, buffer.content);
|
||||||
|
if (!writeResult.ok) {
|
||||||
|
toast.error(`Failed to save: ${writeResult.error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// ... existing markSaved/markTabClean ...
|
||||||
|
toast.success(`Saved ${title}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`useFileStore.openFile`** — error only:
|
||||||
|
```ts
|
||||||
|
openFile: async (filePath) => {
|
||||||
|
// ... existing logic to check existing tab ...
|
||||||
|
const result = await ipc.file.read(filePath);
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(`Failed to open file: ${result.error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ... existing logic ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`useFileStore.openFolder`** — error only:
|
||||||
|
```ts
|
||||||
|
openFolder: async (path) => {
|
||||||
|
const result = await ipc.file.list(path);
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(`Failed to open folder: ${result.error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ... existing logic ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **4 export dialogs** — both success and error (error complements the inline banner):
|
||||||
|
```ts
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(`Export failed: ${result.error.message}`);
|
||||||
|
setError(result.error.message);
|
||||||
|
setSubmitting(false);
|
||||||
|
} else {
|
||||||
|
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. File Map
|
||||||
|
|
||||||
|
### 3.1 Created files
|
||||||
|
|
||||||
|
- `src/renderer/lib/toast.ts` — typed wrappers (re-export of sonner with our naming)
|
||||||
|
- `src/renderer/components/ui/sonner.tsx` — canonical shadcn Toaster wrapper (manual paste)
|
||||||
|
- `tests/unit/lib/toast.test.ts` — ~5 unit tests
|
||||||
|
- `tests/component/ui/sonner.test.tsx` — 1 smoke test
|
||||||
|
- `tests/integration/phase8-toasts-smoke.test.tsx` — ~4 integration tests
|
||||||
|
|
||||||
|
### 3.2 Modified files
|
||||||
|
|
||||||
|
- `src/renderer/App.tsx` — mount `<Toaster />` alongside `<ModalLayer />`
|
||||||
|
- `src/renderer/stores/file-store.ts` — add 3 toast calls (save, openFile, openFolder)
|
||||||
|
- `src/renderer/components/modals/ExportPdfDialog.tsx` — toast on submit result
|
||||||
|
- `src/renderer/components/modals/ExportDocxDialog.tsx` — toast on submit result
|
||||||
|
- `src/renderer/components/modals/ExportHtmlDialog.tsx` — toast on submit result
|
||||||
|
- `src/renderer/components/modals/ExportBatchDialog.tsx` — toast on submit result
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Flow
|
||||||
|
|
||||||
|
User action → store action OR export dialog submit → IPC call → result →
|
||||||
|
- if error: `toast.error(message)` → sonner renders in bottom-right portal
|
||||||
|
- if success: `toast.success(message)` → sonner renders
|
||||||
|
- inline error banner in dialog stays as per-dialog context (NOT removed)
|
||||||
|
|
||||||
|
Theme comes from `useTheme()` in the Toaster component, not from individual toasts. The Toaster is mounted once at the app level.
|
||||||
|
|
||||||
|
For the 4 export dialogs: the existing inline error banner stays (it's contextual to the dialog), AND a toast is fired. This gives the user both immediate context (in the dialog) and persistent notification (toast in corner).
|
||||||
|
|
||||||
|
For file save: ONLY a toast (no inline error UI in the editor pane — keeps the editor clean). Errors still show the toast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Error Handling
|
||||||
|
|
||||||
|
- **Toast helpers never throw.** They are thin re-exports of sonner, which handles its own error cases (e.g., render failures, missing portal target).
|
||||||
|
- **In tests**, the `toast` module is mocked so calls don't render real toasts. Mock signature: `vi.mock('@/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), ... } }))`.
|
||||||
|
- **Theme fallback:** If `useTheme()` is loading or `resolvedTheme` is undefined, Toaster uses `'system'` as a fallback (sonner's default behavior).
|
||||||
|
- **Sonner missing:** If for some reason sonner fails to load, the toast calls become no-ops. The user still sees inline error banners in the export dialogs.
|
||||||
|
- **Inline error banners stay.** They are not replaced by toasts — they complement them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing Strategy
|
||||||
|
|
||||||
|
### 6.1 Unit tests (`tests/unit/lib/toast.test.ts`)
|
||||||
|
|
||||||
|
5 tests:
|
||||||
|
- `toast.success` calls sonner `toast.success` with the message
|
||||||
|
- `toast.error` calls sonner `toast.error` with the message
|
||||||
|
- `toast.info` calls sonner `toast.info`
|
||||||
|
- `toast.warning` calls sonner `toast.warning`
|
||||||
|
- `toast.promise` forwards the promise and messages to sonner
|
||||||
|
|
||||||
|
All tests mock the sonner module and verify the forwarded call.
|
||||||
|
|
||||||
|
### 6.2 Component test (`tests/component/ui/sonner.test.tsx`)
|
||||||
|
|
||||||
|
1 smoke test: `<Toaster />` renders without crashing inside a ThemeProvider.
|
||||||
|
|
||||||
|
### 6.3 Integration test (`tests/integration/phase8-toasts-smoke.test.tsx`)
|
||||||
|
|
||||||
|
~4 tests:
|
||||||
|
- Saving a file → `toast.success` called with `"Saved test.md"`
|
||||||
|
- Opening a non-existent file (ipc returns error) → `toast.error` called with file path in message
|
||||||
|
- Export success (ipc returns ok) → `toast.success` called + dialog closes
|
||||||
|
- Export failure (ipc returns error) → `toast.error` called + inline banner shown + dialog stays open
|
||||||
|
|
||||||
|
TDD throughout. Mocks for `ipc.*` and `lib/toast`.
|
||||||
|
|
||||||
|
### 6.4 Existing tests must still pass
|
||||||
|
|
||||||
|
- All 243 tests from Phase 7 must remain green
|
||||||
|
- Export dialog tests will need updates to verify the new toast calls (the dialog tests currently check for inline banner; they should ALSO verify the toast call)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risks & Open Questions
|
||||||
|
|
||||||
|
**Risks:**
|
||||||
|
- **Sonner theme switching:** If `useTheme()` returns `'system'` and the OS preference changes mid-session, sonner will re-render. This is the correct behavior; just confirm in test.
|
||||||
|
- **Toast spam:** If user holds Cmd+S, the save could fire many times. Sonner deduplicates by default for similar messages? → Decision: rely on sonner's default behavior. If spam becomes an issue, add a `toast.dismiss()` debounce in v2.
|
||||||
|
|
||||||
|
**Open questions (deferrable):**
|
||||||
|
- Should we add a custom toast for "undo last close" (Phase 9's undo feature)? → Decision: out of scope for Phase 8. Phase 9 will handle undo toasts.
|
||||||
|
- Should the export dialog inline banner be removed once toasts are wired? → Decision: NO — keep both. The inline banner is contextual to the dialog; the toast is global. They serve different purposes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out of Scope (deferred to later phases)
|
||||||
|
|
||||||
|
- Phase 9: Undo toasts ("Tab closed — Undo" with action button)
|
||||||
|
- Phase 9: Long-running operation progress toasts (e.g., batch export queue)
|
||||||
|
- Phase 9: Custom toast variants for advanced tools (ASCII generator, table generator)
|
||||||
|
- Phase 10: Cleanup of inline error banners (will evaluate in Phase 10 if redundant)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Success Criteria
|
||||||
|
|
||||||
|
Phase 8 is complete when:
|
||||||
|
- `lib/toast.ts` exists with typed wrappers
|
||||||
|
- `<Toaster />` is mounted in `App.tsx` and renders toasts
|
||||||
|
- `useFileStore.saveActiveBuffer` calls `toast.success`/`toast.error` on result
|
||||||
|
- `useFileStore.openFile` calls `toast.error` on failure
|
||||||
|
- `useFileStore.openFolder` calls `toast.error` on failure
|
||||||
|
- 4 export dialogs call `toast.success`/`toast.error` on result
|
||||||
|
- All tests pass: ~+10 new tests, total ~253
|
||||||
|
- `npx vite build` succeeds
|
||||||
|
- Branch tagged `phase-8-toasts` and pushed to origin
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# Phase 9 — Advanced Tools Design
|
||||||
|
|
||||||
|
> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phase 9 is sketched at high level; this spec locks architecture, file map, and contracts so it can be planned task-by-task.)
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Phase:** 9 of 10 (React + shadcn/ui UI redesign)
|
||||||
|
**Tag (on completion):** `phase-9-advanced-tools`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal & Non-Goals
|
||||||
|
|
||||||
|
**Goal:** Add 10 advanced tools to the React renderer. Group 1: Standalone dialogs (ASCII generator, Table generator, Word export, Find-in-files). Group 2: Global overlays (Zen mode, REPL, Print preview). Group 3: Editor/sidebar integrations (Minimap, Breadcrumbs-with-symbols, Git status). All triggered via the Phase 6 command store.
|
||||||
|
|
||||||
|
**Non-goals (Phase 9):**
|
||||||
|
- True undo/redo stack (still not in scope)
|
||||||
|
- Snippet/template library
|
||||||
|
- Custom REPL with full JS eval (we chose markdown snippet preview for safety)
|
||||||
|
- Custom diff/merge UI (just shows git status, no in-app diffing)
|
||||||
|
- Plug-in extensions
|
||||||
|
- Multi-cursor editing
|
||||||
|
- LSP / language server integration
|
||||||
|
|
||||||
|
**Decision summary (from brainstorming):**
|
||||||
|
- REPL = **markdown snippet preview** (no JS eval — safe in renderer)
|
||||||
|
- Word export = **`.docx` via `docx` lib in renderer**, with both Standard and Custom .dotx template modes
|
||||||
|
- Find-in-files = **recursive search with result navigation** (new IPC, regex support)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
### 2.1 Three mount strategies
|
||||||
|
|
||||||
|
| Category | Mount | Examples |
|
||||||
|
|---|---|---|
|
||||||
|
| **Dialogs** | `<ModalLayer />` (Phase 7 pattern) | ASCII gen, Table gen, Word export, Find-in-files |
|
||||||
|
| **Global overlays** | Top-level in `App.tsx` (like `<Toaster />`) | Zen mode, REPL panel, Print preview |
|
||||||
|
| **Editor/sidebar integrations** | Extend existing components | Minimap, Breadcrumbs, Git status |
|
||||||
|
|
||||||
|
The ModalLayer is the dispatcher for dialogs. The global overlays are mounted directly in `App.tsx` because they need to participate in the top-level layout (full-window, or pinned to the bottom).
|
||||||
|
|
||||||
|
### 2.2 ModalState union extension
|
||||||
|
|
||||||
|
The Phase 7 `ModalState` discriminated union (9 kinds) gets 4 new kinds for the new dialogs:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ModalState =
|
||||||
|
| { kind: null }
|
||||||
|
| { kind: 'export-pdf'; props: { sourcePath: string } }
|
||||||
|
| { kind: 'export-docx'; props: { sourcePath: string } }
|
||||||
|
| { kind: 'export-html'; props: { sourcePath: string } }
|
||||||
|
| { kind: 'export-batch'; props: { sourcePaths: string[] } }
|
||||||
|
| { kind: 'export-word'; props: { sourcePath: string } } // NEW
|
||||||
|
| { kind: 'ascii-generator' } // NEW
|
||||||
|
| { kind: 'table-generator' } // NEW
|
||||||
|
| { kind: 'find-in-files' } // NEW
|
||||||
|
| { kind: 'settings' }
|
||||||
|
| { kind: 'about' }
|
||||||
|
| { kind: 'welcome' }
|
||||||
|
| { kind: 'confirm'; props: ConfirmProps };
|
||||||
|
```
|
||||||
|
|
||||||
|
Three of the four new kinds (ascii, table, find-in-files) take no props — they read from the active buffer via `useExportSource` (for ascii/table) or from `useFileStore.rootPath` (for find-in-files). `export-word` takes `sourcePath` like the other export dialogs.
|
||||||
|
|
||||||
|
### 2.3 New commands in command store
|
||||||
|
|
||||||
|
| Command ID | Trigger | Opens |
|
||||||
|
|---|---|---|
|
||||||
|
| `tools.ascii` | new | `AsciiGeneratorDialog` |
|
||||||
|
| `tools.table` | new | `TableGeneratorDialog` |
|
||||||
|
| `tools.exportWord` | new | `WordExportDialog` |
|
||||||
|
| `tools.findInFiles` | new | `FindInFilesDialog` |
|
||||||
|
| `tools.repl` | new | toggles REPL panel |
|
||||||
|
| `view.zenMode` | existing (Phase 6) | toggles Zen mode overlay |
|
||||||
|
| `file.print` | new | opens `PrintPreview` overlay |
|
||||||
|
| `git.refresh` | new | re-fetches git status |
|
||||||
|
|
||||||
|
All registered in `src/renderer/lib/commands/register-menu-commands.ts`.
|
||||||
|
|
||||||
|
### 2.4 New IPC surface
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/renderer/lib/ipc.ts (additions)
|
||||||
|
ipc.file.search({ rootPath, query, isRegex, caseSensitive }: {
|
||||||
|
rootPath: string;
|
||||||
|
query: string;
|
||||||
|
isRegex: boolean;
|
||||||
|
caseSensitive: boolean;
|
||||||
|
}): Promise<IpcResult<Array<{ filePath: string; line: number; content: string }>>>;
|
||||||
|
|
||||||
|
ipc.file.gitStatus({ rootPath }: { rootPath: string }): Promise<IpcResult<Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }>>>;
|
||||||
|
|
||||||
|
ipc.file.print({ html }: { html: string }): Promise<IpcResult<void>>;
|
||||||
|
|
||||||
|
ipc.file.writeBuffer({ path, buffer }: { path: string; buffer: Uint8Array }): Promise<IpcResult<void>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The main process counterparts are added to `src/main.js` (or a new `src/main/files-search.js`, etc.). For Phase 9, the spec covers the renderer-side design; main-process IPC handlers are assumed to follow the same `ipcMain.handle` pattern as the existing handlers.
|
||||||
|
|
||||||
|
### 2.5 Settings additions (`useSettingsStore`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// zod schema additions:
|
||||||
|
docxCustomTemplatePath: z.string().nullable().default(null), // path to user .dotx file
|
||||||
|
replOpen: z.boolean().default(false), // REPL panel visibility
|
||||||
|
breadcrumbSymbols: z.boolean().default(true), // breadcrumbs show code symbols
|
||||||
|
// minimap already exists from Phase 7 (useSettingsStore.minimap: z.boolean().default(true))
|
||||||
|
```
|
||||||
|
|
||||||
|
The `minimap` setting from Phase 7 is now wired (not just stored).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. File Map
|
||||||
|
|
||||||
|
### 3.1 Created files
|
||||||
|
|
||||||
|
**Dialogs (in `src/renderer/components/modals/`):**
|
||||||
|
- `AsciiGeneratorDialog.tsx` — input textarea, font select, output preview with copy button
|
||||||
|
- `TableGeneratorDialog.tsx` — rows × cols inputs, header checkbox, output preview
|
||||||
|
- `WordExportDialog.tsx` — template select (Standard / Custom .dotx), options, preview, export
|
||||||
|
- `FindInFilesDialog.tsx` — query input, regex/case toggles, results list with click-to-navigate
|
||||||
|
|
||||||
|
**Global overlays (in `src/renderer/components/tools/`):**
|
||||||
|
- `ReplPanel.tsx` — bottom-pinned, textarea + rendered preview
|
||||||
|
- `PrintPreview.tsx` — full-window print preview
|
||||||
|
|
||||||
|
**Editor/sidebar integrations:**
|
||||||
|
- `src/renderer/components/layout/ZenMode.tsx` — wraps/controls the editor in zen mode (or modify `AppShell.tsx` to hide chrome)
|
||||||
|
- `src/renderer/components/sidebar/GitStatusPanel.tsx` — file list with status badges
|
||||||
|
|
||||||
|
**Lib:**
|
||||||
|
- `src/renderer/lib/docx-export.ts` — renderer-side `docx` lib integration (markdown → Blob)
|
||||||
|
- `src/renderer/hooks/use-zen-mode.ts` — small hook that reads `useSettingsStore.zenMode`
|
||||||
|
|
||||||
|
**Modifications:**
|
||||||
|
- `src/renderer/App.tsx` — mount `<ReplPanel />` and `<PrintPreview />` alongside `<ModalLayer />` and `<Toaster />`
|
||||||
|
- `src/renderer/components/layout/AppShell.tsx` — when `zenMode === true`, hide all chrome except the editor
|
||||||
|
- `src/renderer/components/editor/CodeMirrorEditor.tsx` — add `@codemirror/minimap` (or `@replit/codemirror-minimap`) when `useSettingsStore.minimap` is true
|
||||||
|
- `src/renderer/components/layout/Breadcrumb.tsx` — extend to show symbols (headings, code blocks) using `@codemirror/langs-data` or a simple markdown AST walk
|
||||||
|
- `src/renderer/components/sidebar/Sidebar.tsx` — add a "Git" tab to the existing tab list
|
||||||
|
- `src/renderer/stores/settings-store.ts` — add 3 new fields
|
||||||
|
- `src/renderer/lib/validators.ts` — add 3 new fields to `settingsSchema`
|
||||||
|
- `src/renderer/lib/ipc.ts` — add 4 new IPC methods
|
||||||
|
- `src/renderer/lib/commands/register-menu-commands.ts` — add 8 new commands
|
||||||
|
- `src/main.js` (or split files) — add 4 main-process IPC handlers
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
- `tests/component/tools/ReplPanel.test.tsx` — smoke test
|
||||||
|
- `tests/component/tools/PrintPreview.test.tsx` — smoke test
|
||||||
|
- `tests/component/modals/AsciiGeneratorDialog.test.tsx` — 3 tests
|
||||||
|
- `tests/component/modals/TableGeneratorDialog.test.tsx` — 3 tests
|
||||||
|
- `tests/component/modals/WordExportDialog.test.tsx` — 4 tests
|
||||||
|
- `tests/component/modals/FindInFilesDialog.test.tsx` — 4 tests
|
||||||
|
- `tests/component/sidebar/GitStatusPanel.test.tsx` — 3 tests
|
||||||
|
- `tests/component/layout/Breadcrumb.test.tsx` — extend with symbols test
|
||||||
|
- `tests/integration/phase9-tools-smoke.test.tsx` — 6 tests (one per command-triggered tool)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Flow
|
||||||
|
|
||||||
|
### 4.1 Word export (most complex)
|
||||||
|
|
||||||
|
1. User triggers `tools.exportWord` → `registerMenuCommands` opens the export-word modal via `useAppStore.openModal('export-word', { sourcePath: activeTabId })`
|
||||||
|
2. **Wait** — we need a new modal kind `export-word` in the `ModalState` union. Add it.
|
||||||
|
3. `WordExportDialog` (mounted by ModalLayer) shows:
|
||||||
|
- Template select: "Standard (bundled)" or "Custom .dotx (your file)" — pre-populated with `useSettingsStore.docxCustomTemplatePath`
|
||||||
|
- "Choose custom template..." button (file picker, saves to settings)
|
||||||
|
- Options: embed images (checkbox), include front matter (checkbox)
|
||||||
|
- Preview area: shows the generated docx structure (sizes, table of contents)
|
||||||
|
4. On submit:
|
||||||
|
- Renderer calls `lib/docx-export.ts#generateDocx(source, templatePath, options)` which uses the `docx` lib
|
||||||
|
- The lib takes a `Document` AST and produces a Blob
|
||||||
|
- ASCII tables are converted to monospace `<w:r>` runs (using `applyAsciiTransform` from Phase 7)
|
||||||
|
- Images are extracted from markdown and embedded as base64
|
||||||
|
- If a custom .dotx path is set, the renderer reads it via `ipc.file.read` and applies its styles (via `docx` lib's style support)
|
||||||
|
- User picks output path via `ipc.app.showSaveDialog`
|
||||||
|
- Writes Blob via `ipc.file.writeBuffer` (new)
|
||||||
|
- Toast success/failure
|
||||||
|
|
||||||
|
### 4.2 Find-in-files
|
||||||
|
|
||||||
|
1. User triggers `tools.findInFiles` → ModalLayer opens `FindInFilesDialog`
|
||||||
|
2. User types query, picks regex/literal, case-sensitive toggle
|
||||||
|
3. On submit: `ipc.file.search({ rootPath: useFileStore.rootPath, query, isRegex, caseSensitive })`
|
||||||
|
4. Backend walks the disk recursively, applies regex/literal match, returns `Array<{ filePath, line, content }>`
|
||||||
|
5. Dialog shows result list (file:line:content, clickable)
|
||||||
|
6. Click a result:
|
||||||
|
- `useFileStore.openFile(filePath)` if not already open
|
||||||
|
- Set the cursor in the editor to the matched line
|
||||||
|
7. Close the dialog
|
||||||
|
|
||||||
|
### 4.3 REPL (markdown snippet preview)
|
||||||
|
|
||||||
|
1. User triggers `tools.repl` → toggles `useSettingsStore.replOpen` (or a new dedicated `useReplStore`)
|
||||||
|
2. `ReplPanel` (mounted in App.tsx) is visible when `replOpen === true`
|
||||||
|
3. The panel has:
|
||||||
|
- Top half: textarea (user types/pastes markdown)
|
||||||
|
- Bottom half: rendered preview (uses `lib/markdown.ts` from Phase 1 + DOMPurify)
|
||||||
|
4. Updates are debounced (300ms) for the preview
|
||||||
|
5. Pure renderer-side, no IPC
|
||||||
|
|
||||||
|
### 4.4 Zen mode
|
||||||
|
|
||||||
|
1. User triggers `view.zenMode` → toggles `useSettingsStore.zenMode`
|
||||||
|
2. `AppShell` reads `zenMode` from store; when true, hides all chrome (header, tabs, toolbar, breadcrumb, status bar, sidebar)
|
||||||
|
3. Editor goes fullscreen
|
||||||
|
4. Pressing Esc exits zen mode (keydown listener in `use-zen-mode` hook)
|
||||||
|
|
||||||
|
### 4.5 Print preview
|
||||||
|
|
||||||
|
1. User triggers `file.print` → opens `PrintPreview` (full-window overlay)
|
||||||
|
2. The preview renders the current buffer's content as it would appear in print
|
||||||
|
3. Two buttons: "Print" (calls `ipc.file.print({ html })` which opens native print dialog) and "Close"
|
||||||
|
|
||||||
|
### 4.6 Minimap
|
||||||
|
|
||||||
|
1. `useSettingsStore.minimap` (existing setting) is read by `CodeMirrorEditor`
|
||||||
|
2. When true, the editor's extension includes `@replit/codemirror-minimap` (or a simpler custom implementation)
|
||||||
|
3. The minimap shows a shrunk version of the document on the right side of the editor
|
||||||
|
4. Toggling the setting adds/removes the extension dynamically
|
||||||
|
|
||||||
|
### 4.7 Breadcrumbs-with-symbols
|
||||||
|
|
||||||
|
1. The current `Breadcrumb` shows the file path. Extend it to also show markdown symbols (headings, code blocks)
|
||||||
|
2. Use a simple AST walk of the current buffer to extract heading levels
|
||||||
|
3. Show as a path-like navigation: `file.md > H1: Title > H2: Section > #line`
|
||||||
|
4. The `useSettingsStore.breadcrumbSymbols` toggle (default true) controls whether symbols are shown
|
||||||
|
|
||||||
|
### 4.8 Git status
|
||||||
|
|
||||||
|
1. On sidebar Git tab open, fetch `ipc.file.gitStatus({ rootPath: useFileStore.rootPath })`
|
||||||
|
2. The main process runs `git status --porcelain` (or equivalent) and returns the list
|
||||||
|
3. Show file list with status badges (M, A, D, ?)
|
||||||
|
4. Click a file opens it via `useFileStore.openFile`
|
||||||
|
5. `git.refresh` re-fetches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Error Handling
|
||||||
|
|
||||||
|
- **Word export failures:** `lib/docx-export.ts` catches `docx` lib errors → toast.error
|
||||||
|
- **Word export missing .dotx:** if custom template path is set but file doesn't exist, show inline banner in the dialog
|
||||||
|
- **Find-in-files failures:** if `ipc.file.search` throws (regex syntax error, IO error), show inline banner in the dialog
|
||||||
|
- **REPL:** no IPC, no error handling needed
|
||||||
|
- **Git status:** if folder isn't a git repo, return empty array. Panel shows "Not a git repository" message
|
||||||
|
- **Print:** if `ipc.file.print` fails (no printer, user cancels), toast.error or silent close
|
||||||
|
- **Minimap, Breadcrumbs:** renderer-side, no errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing Strategy
|
||||||
|
|
||||||
|
- **Unit tests:** `lib/docx-export.ts` (markdown → docx conversion, ASCII handling), `lib/ascii-table.ts` (already tested)
|
||||||
|
- **Component tests:** 1-2 smoke tests per new component
|
||||||
|
- **Integration test:** `phase9-tools-smoke.test.tsx` covering the 6 command-triggered tools (dispatch opens correct modal/overlay)
|
||||||
|
- **Editor integrations:** minimap toggle, breadcrumbs render with symbols, git status panel render
|
||||||
|
- **Target: +30-40 new tests**, total ~290
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risks & Open Questions
|
||||||
|
|
||||||
|
**Risks:**
|
||||||
|
- **`docx` lib bundle size (~500KB).** Acceptable for a desktop app but worth noting. Lazy-load if it becomes a concern.
|
||||||
|
- **Word export custom template parsing** — the `.dotx` format is a zip with XML inside. The `docx` lib may not fully support reading existing templates. v1 may need a simpler "styles only" extraction.
|
||||||
|
- **Find-in-files regex compilation** — invalid regex would throw. Validate on the renderer before calling IPC.
|
||||||
|
- **Minimap performance** — for large files, the minimap can slow down scrolling. CodeMirror's built-in minimap has performance options to configure.
|
||||||
|
|
||||||
|
**Open questions (deferrable):**
|
||||||
|
- **REPL persistence** — should the textarea content survive across modal close/reopen? → Decision: NO for v1. Keep simple.
|
||||||
|
- **Git status auto-refresh** — should it auto-refresh every N seconds? → Decision: NO for v1. Manual via `git.refresh` command.
|
||||||
|
- **Find-in-files result count limit** — what if there are 10,000 matches? → Decision: limit to 500 results for v1, show "X more matches" message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out of Scope (deferred to Phase 10 or later)
|
||||||
|
|
||||||
|
- Custom undo/redo (still not in scope)
|
||||||
|
- Snippet/template library
|
||||||
|
- Multi-cursor editing
|
||||||
|
- LSP / language server
|
||||||
|
- Phase 10 will delete legacy files including the old `src/print-preview.js`, `src/wordTemplateExporter.js`, `src/welcome.js`, `src/zen-mode.js`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Success Criteria
|
||||||
|
|
||||||
|
Phase 9 is complete when:
|
||||||
|
- All 10 features implemented and accessible via the command store
|
||||||
|
- `lib/docx-export.ts` generates valid `.docx` files
|
||||||
|
- Find-in-files returns results from a recursive disk walk
|
||||||
|
- REPL renders markdown snippet previews
|
||||||
|
- Zen mode hides all chrome and Esc exits
|
||||||
|
- Print preview opens native print dialog
|
||||||
|
- Minimap appears when `useSettingsStore.minimap` is true
|
||||||
|
- Breadcrumbs show symbols by default
|
||||||
|
- Git status panel shows modified/added/deleted/untracked files
|
||||||
|
- ~+30-40 new tests, total ~290
|
||||||
|
- `npx vite build` succeeds
|
||||||
|
- Branch tagged `phase-9-advanced-tools` and pushed to origin
|
||||||
Generated
+37
-4
@@ -42,11 +42,12 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.7.1",
|
||||||
"docx4js": "^2.0.1",
|
"docx4js": "^2.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"figlet": "^1.11.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"immer": "^11.1.8",
|
"immer": "^11.1.8",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/figlet": "^1.7.0",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.16",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -5737,6 +5739,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/figlet": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/figlet/-/figlet-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-KwrT7p/8Eo3Op/HBSIwGXOsTZKYiM9NpWRBJ5sVjWP/SmlS+oxxRvJht/FNAtliJvja44N3ul1yATgohnVBV0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/fs-extra": {
|
"node_modules/@types/fs-extra": {
|
||||||
"version": "9.0.13",
|
"version": "9.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
|
||||||
@@ -9161,9 +9170,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/docx": {
|
"node_modules/docx": {
|
||||||
"version": "9.6.1",
|
"version": "9.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
|
||||||
"integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==",
|
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
@@ -10241,6 +10250,30 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/figlet": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/figlet/-/figlet-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-EEx3OS/l2bFqcUNN2NM9FPJp8vAMrgbCxsbl2hbcJNNxOEwVe3mEzrhan7TbJQViZa8mMqhihlbCaqD+LyYKTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^14.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"figlet": "bin/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/figlet/node_modules/commander": {
|
||||||
|
"version": "14.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||||
|
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -51,6 +51,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/figlet": "^1.7.0",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.16",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -113,11 +114,12 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.7.1",
|
||||||
"docx4js": "^2.0.1",
|
"docx4js": "^2.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"figlet": "^1.11.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"immer": "^11.1.8",
|
"immer": "^11.1.8",
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { AppShell } from './components/layout/AppShell';
|
import { AppShell } from './components/layout/AppShell';
|
||||||
import { ModalLayer } from './components/modals/ModalLayer';
|
import { ModalLayer } from './components/modals/ModalLayer';
|
||||||
|
import { Toaster } from './components/ui/sonner';
|
||||||
|
import { ReplPanel } from './components/tools/ReplPanel';
|
||||||
|
import { PrintPreview } from './components/tools/PrintPreview';
|
||||||
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
|
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
useWelcomeTrigger();
|
useWelcomeTrigger();
|
||||||
|
const [printOpen, setPrintOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setPrintOpen(true);
|
||||||
|
window.addEventListener('mc:print', handler);
|
||||||
|
return () => window.removeEventListener('mc:print', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppShell />
|
<AppShell />
|
||||||
<ModalLayer />
|
<ModalLayer />
|
||||||
|
<Toaster />
|
||||||
|
<ReplPanel />
|
||||||
|
{printOpen && <PrintPreview onClose={() => setPrintOpen(false)} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { oneDark } from '@codemirror/theme-one-dark';
|
|||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { lightTheme, lightHighlight } from './themes/light';
|
import { lightTheme, lightHighlight } from './themes/light';
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { Minimap } from './Minimap';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bufferId: string;
|
bufferId: string;
|
||||||
@@ -24,6 +26,7 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
|||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const updateContent = useEditorStore((s) => s.updateContent);
|
const updateContent = useEditorStore((s) => s.updateContent);
|
||||||
const setCursor = useEditorStore((s) => s.setCursor);
|
const setCursor = useEditorStore((s) => s.setCursor);
|
||||||
|
const minimap = useSettingsStore((s) => s.minimap);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
@@ -76,5 +79,10 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
|||||||
});
|
});
|
||||||
}, [resolvedTheme]);
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
return <div ref={ref} className="h-full overflow-hidden" />;
|
return (
|
||||||
|
<div className="relative h-full overflow-hidden">
|
||||||
|
<div ref={ref} className="h-full overflow-hidden" />
|
||||||
|
{minimap && <Minimap content={initialContent} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
scrollRatio?: number; // 0-1, where the viewport is
|
||||||
|
visibleRatio?: number; // 0-1, what fraction of content is visible
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Minimap({ content, scrollRatio = 0, visibleRatio = 1 }: Props) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
// Compute viewport position in the minimap
|
||||||
|
const viewportTop = Math.round(scrollRatio * 100);
|
||||||
|
const viewportHeight = Math.round(visibleRatio * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="minimap"
|
||||||
|
className="pointer-events-none absolute right-0 top-0 h-full w-[100px] overflow-hidden border-l border-border bg-card/30 p-1 font-mono text-[6px] leading-[8px] text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<pre className="w-full truncate whitespace-pre">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div key={i} data-testid="minimap-line">
|
||||||
|
{line || ' '}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
<div
|
||||||
|
data-testid="minimap-viewport"
|
||||||
|
className="pointer-events-none absolute left-0 right-0 bg-brand/15"
|
||||||
|
style={{ top: `${viewportTop}%`, height: `${Math.max(viewportHeight, 5)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,13 +11,43 @@ import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/componen
|
|||||||
import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
|
import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
|
||||||
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
|
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
|
||||||
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
|
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
|
||||||
|
import { useZenMode } from '@/hooks/use-zen-mode';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
useFileShortcuts();
|
useFileShortcuts();
|
||||||
useRestoreLastFolder();
|
useRestoreLastFolder();
|
||||||
useRegisterMenuCommands();
|
useRegisterMenuCommands();
|
||||||
useBridgeNativeMenu();
|
useBridgeNativeMenu();
|
||||||
|
useZenMode();
|
||||||
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
||||||
|
const zenMode = useAppStore((s) => s.zenMode);
|
||||||
|
|
||||||
|
if (zenMode) {
|
||||||
|
return (
|
||||||
|
<main className="h-screen w-screen overflow-hidden bg-background">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
onLayout={(sizes) => setPaneSizes({ sidebar: 0, editor: sizes[0], preview: sizes[1] })}
|
||||||
|
>
|
||||||
|
<ResizablePanel defaultSize={previewVisible ? 50 : 100} minSize={20}>
|
||||||
|
<section className="h-full bg-background">
|
||||||
|
<EditorPane />
|
||||||
|
</section>
|
||||||
|
</ResizablePanel>
|
||||||
|
{previewVisible && (
|
||||||
|
<>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20}>
|
||||||
|
<section className="h-full border-l border-border bg-card/10">
|
||||||
|
<PreviewPane />
|
||||||
|
</section>
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
function extractHeadings(content: string): { level: number; text: string }[] {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const headings: { level: number; text: string }[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(/^(#{1,6})\s+(.+)$/);
|
||||||
|
if (m) headings.push({ level: m[1].length, text: m[2].trim() });
|
||||||
|
}
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
export function Breadcrumb() {
|
export function Breadcrumb() {
|
||||||
|
const activeTabId = useFileStore((s) => s.activeTabId);
|
||||||
|
const openTabs = useFileStore((s) => s.openTabs);
|
||||||
|
const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined));
|
||||||
|
const showSymbols = useSettingsStore((s) => s.breadcrumbSymbols);
|
||||||
|
|
||||||
|
const tab = activeTabId ? openTabs.find((t) => t.id === activeTabId) : null;
|
||||||
|
const headings = showSymbols && buffer ? extractHeadings(buffer.content).slice(0, 3) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground">
|
<nav
|
||||||
<span>No file selected</span>
|
aria-label="File path"
|
||||||
|
className="flex h-7 items-center gap-1 border-b border-border bg-card/10 px-3 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>{tab ? tab.title : 'No file selected'}</span>
|
||||||
|
{headings.map((h, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<span aria-hidden="true">›</span>
|
||||||
|
<span className="truncate">{'#'.repeat(h.level)} {h.text}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
import { figletText, FIGLET_FONTS, type FigletFont } from '@/lib/figlet';
|
||||||
|
|
||||||
|
export function AsciiGeneratorDialog() {
|
||||||
|
const closeModal = useAppStore((s) => s.closeModal);
|
||||||
|
const [text, setText] = useState('Hello');
|
||||||
|
const [font, setFont] = useState<FigletFont>('Standard');
|
||||||
|
const [output, setOutput] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
figletText(text || ' ', font)
|
||||||
|
.then((result) => { if (!cancelled) setOutput(result); })
|
||||||
|
.catch(() => { if (!cancelled) setOutput('(render error)'); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [text, font]);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(output);
|
||||||
|
toast.success('Copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
|
<DialogContent aria-describedby="ascii-desc" className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>ASCII generator</DialogTitle>
|
||||||
|
<DialogDescription id="ascii-desc">Type text, pick a font, see ASCII art</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ascii-text">Text</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ascii-text"
|
||||||
|
aria-label="Text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ascii-font">Font</Label>
|
||||||
|
<Select value={font} onValueChange={(v) => setFont(v as FigletFont)}>
|
||||||
|
<SelectTrigger id="ascii-font" aria-label="Font"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FIGLET_FONTS.map((f) => (
|
||||||
|
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Output</Label>
|
||||||
|
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="ascii-output">
|
||||||
|
{output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={closeModal}>Close</Button>
|
||||||
|
<Button onClick={handleCopy}>Copy</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
|
|
||||||
const extFor = (format: 'pdf' | 'docx' | 'html' | 'png') =>
|
const extFor = (format: 'pdf' | 'docx' | 'html' | 'png') =>
|
||||||
@@ -25,9 +26,11 @@ export function ExportBatchDialog({ sourcePaths }: { sourcePaths: string[] }) {
|
|||||||
}));
|
}));
|
||||||
const result = await ipc.export.batch(items, { format, concurrency });
|
const result = await ipc.export.batch(items, { format, concurrency });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
toast.error(`Export failed: ${result.error.message}`);
|
||||||
setError(result.error.message);
|
setError(result.error.message);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} else {
|
} else {
|
||||||
|
toast.success(`Exported ${sourcePaths.length} files`);
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAppStore } from '@/stores/app-store';
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
|
|
||||||
export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
||||||
@@ -29,9 +30,11 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
renderTablesAsAscii: ascii,
|
renderTablesAsAscii: ascii,
|
||||||
} as any);
|
} as any);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
toast.error(`Export failed: ${result.error.message}`);
|
||||||
setError(result.error.message);
|
setError(result.error.message);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} else {
|
} else {
|
||||||
|
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAppStore } from '@/stores/app-store';
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
|
|
||||||
export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
||||||
@@ -31,9 +32,11 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
renderTablesAsAscii: ascii,
|
renderTablesAsAscii: ascii,
|
||||||
} as any);
|
} as any);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
toast.error(`Export failed: ${result.error.message}`);
|
||||||
setError(result.error.message);
|
setError(result.error.message);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} else {
|
} else {
|
||||||
|
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAppStore } from '@/stores/app-store';
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
|
|
||||||
const MARGIN_MAP = {
|
const MARGIN_MAP = {
|
||||||
@@ -43,10 +44,12 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
renderTablesAsAscii: ascii,
|
renderTablesAsAscii: ascii,
|
||||||
fontSize,
|
fontSize,
|
||||||
} as any);
|
} as any);
|
||||||
if (!result.data?.ok) {
|
if (!result.ok) {
|
||||||
setError(result.data?.error?.message ?? 'Export failed');
|
toast.error(`Export failed: ${result.error?.message ?? 'Export failed'}`);
|
||||||
|
setError(result.error?.message ?? 'Export failed');
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} else {
|
} else {
|
||||||
|
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
filePath: string;
|
||||||
|
line: number;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindInFilesDialog() {
|
||||||
|
const closeModal = useAppStore((s) => s.closeModal);
|
||||||
|
const rootPath = useFileStore((s) => s.rootPath);
|
||||||
|
const openFile = useFileStore((s) => s.openFile);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [isRegex, setIsRegex] = useState(false);
|
||||||
|
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!query || !rootPath) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await ipc.file.search({ rootPath, query, isRegex, caseSensitive });
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error.message);
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResults(result.data ?? []);
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultClick = (filePath: string) => {
|
||||||
|
openFile(filePath);
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
|
<DialogContent aria-describedby="find-desc" className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Find in files</DialogTitle>
|
||||||
|
<DialogDescription id="find-desc">
|
||||||
|
{rootPath ? `Search in ${rootPath}` : 'No folder open'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="find-query">Query</Label>
|
||||||
|
<Input
|
||||||
|
id="find-query"
|
||||||
|
aria-label="Query"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={isRegex} onCheckedChange={(c) => setIsRegex(!!c)} aria-label="Regex" />
|
||||||
|
Regex
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={caseSensitive} onCheckedChange={(c) => setCaseSensitive(!!c)} aria-label="Case sensitive" />
|
||||||
|
Case sensitive
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label>{results.length} result{results.length === 1 ? '' : 's'}</Label>
|
||||||
|
<div className="max-h-64 overflow-auto rounded border border-border bg-card/20 text-xs">
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={`${r.filePath}:${r.line}:${i}`}
|
||||||
|
onClick={() => handleResultClick(r.filePath)}
|
||||||
|
className="block w-full truncate border-b border-border/30 px-2 py-1 text-left hover:bg-card/50"
|
||||||
|
data-testid="find-result"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-muted-foreground">{r.filePath}:{r.line}</span>
|
||||||
|
<span className="ml-2">{r.content}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Close</Button>
|
||||||
|
<Button onClick={handleSearch} disabled={submitting || !query}>
|
||||||
|
{submitting ? 'Searching…' : 'Search'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { AboutDialog } from './AboutDialog';
|
import { AboutDialog } from './AboutDialog';
|
||||||
|
import { AsciiGeneratorDialog } from './AsciiGeneratorDialog';
|
||||||
import { ConfirmDialog } from './ConfirmDialog';
|
import { ConfirmDialog } from './ConfirmDialog';
|
||||||
import { ExportBatchDialog } from './ExportBatchDialog';
|
import { ExportBatchDialog } from './ExportBatchDialog';
|
||||||
import { ExportDocxDialog } from './ExportDocxDialog';
|
import { ExportDocxDialog } from './ExportDocxDialog';
|
||||||
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
||||||
import { ExportPdfDialog } from './ExportPdfDialog';
|
import { ExportPdfDialog } from './ExportPdfDialog';
|
||||||
|
import { FindInFilesDialog } from './FindInFilesDialog';
|
||||||
import { SettingsSheet } from './SettingsSheet';
|
import { SettingsSheet } from './SettingsSheet';
|
||||||
|
import { TableGeneratorDialog } from './TableGeneratorDialog';
|
||||||
import { WelcomeDialog } from './WelcomeDialog';
|
import { WelcomeDialog } from './WelcomeDialog';
|
||||||
|
import { WordExportDialog } from './WordExportDialog';
|
||||||
|
|
||||||
export function ModalLayer() {
|
export function ModalLayer() {
|
||||||
const modal = useAppStore((s) => s.modal);
|
const modal = useAppStore((s) => s.modal);
|
||||||
@@ -29,5 +33,13 @@ export function ModalLayer() {
|
|||||||
return <WelcomeDialog />;
|
return <WelcomeDialog />;
|
||||||
case 'confirm':
|
case 'confirm':
|
||||||
return <ConfirmDialog {...modal.props} />;
|
return <ConfirmDialog {...modal.props} />;
|
||||||
|
case 'export-word':
|
||||||
|
return <WordExportDialog sourcePath={modal.props.sourcePath} />;
|
||||||
|
case 'ascii-generator':
|
||||||
|
return <AsciiGeneratorDialog />;
|
||||||
|
case 'table-generator':
|
||||||
|
return <TableGeneratorDialog />;
|
||||||
|
case 'find-in-files':
|
||||||
|
return <FindInFilesDialog />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
function pad(s: string, w: number) {
|
||||||
|
return s.padEnd(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTable(rows: number, cols: number, hasHeader: boolean, headers: string[], data: string[][]): string {
|
||||||
|
const width = cols;
|
||||||
|
const colWidths: number[] = Array.from({ length: width }, (_, c) => {
|
||||||
|
const all = [headers[c] ?? '', ...data.map((r) => r[c] ?? '')];
|
||||||
|
return Math.max(...all.map((s) => s.length), 3);
|
||||||
|
});
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (hasHeader) {
|
||||||
|
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(headers[c] ?? `Col ${c + 1}`, colWidths[c])).join(' | ') + ' |');
|
||||||
|
lines.push('| ' + colWidths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
for (const row of data) {
|
||||||
|
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(row[c] ?? '', colWidths[c])).join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
// Add empty rows if data has fewer
|
||||||
|
for (let i = data.length; i < rows; i++) {
|
||||||
|
lines.push('| ' + colWidths.map((w) => pad('', w)).join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableGeneratorDialog() {
|
||||||
|
const closeModal = useAppStore((s) => s.closeModal);
|
||||||
|
const [rows, setRows] = useState(3);
|
||||||
|
const [cols, setCols] = useState(3);
|
||||||
|
const [hasHeader, setHasHeader] = useState(true);
|
||||||
|
const [headers, setHeaders] = useState<string[]>(['Col 1', 'Col 2', 'Col 3']);
|
||||||
|
|
||||||
|
const output = generateTable(rows, cols, hasHeader, headers, []);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(output);
|
||||||
|
toast.success('Copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHeadersCount = (newCols: number) => {
|
||||||
|
setCols(newCols);
|
||||||
|
setHeaders((prev) => {
|
||||||
|
if (prev.length < newCols) return [...prev, ...Array(newCols - prev.length).fill('').map((_, i) => `Col ${prev.length + i + 1}`)];
|
||||||
|
return prev.slice(0, newCols);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
|
<DialogContent aria-describedby="table-desc" className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Table generator</DialogTitle>
|
||||||
|
<DialogDescription id="table-desc">Specify rows × columns to generate a markdown table</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="table-rows">Rows</Label>
|
||||||
|
<Input
|
||||||
|
id="table-rows"
|
||||||
|
aria-label="Rows"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={rows}
|
||||||
|
onChange={(e) => setRows(Math.max(1, Math.min(50, Number(e.target.value) || 1)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="table-cols">Columns</Label>
|
||||||
|
<Input
|
||||||
|
id="table-cols"
|
||||||
|
aria-label="Cols"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={cols}
|
||||||
|
onChange={(e) => updateHeadersCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={hasHeader} onCheckedChange={(c) => setHasHeader(!!c)} aria-label="Header" />
|
||||||
|
Include header row
|
||||||
|
</label>
|
||||||
|
{hasHeader && (
|
||||||
|
<div>
|
||||||
|
<Label>Header names</Label>
|
||||||
|
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
|
||||||
|
{Array.from({ length: cols }, (_, i) => (
|
||||||
|
<Input
|
||||||
|
key={i}
|
||||||
|
aria-label={`Header ${i + 1}`}
|
||||||
|
value={headers[i] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...headers];
|
||||||
|
next[i] = e.target.value;
|
||||||
|
setHeaders(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Label>Output</Label>
|
||||||
|
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="table-output">
|
||||||
|
{output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={closeModal}>Close</Button>
|
||||||
|
<Button onClick={handleCopy}>Copy</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
|
import { generateDocx } from '@/lib/docx-export';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
export function WordExportDialog({ sourcePath }: { sourcePath: string }) {
|
||||||
|
const closeModal = useAppStore((s) => s.closeModal);
|
||||||
|
const setSetting = useSettingsStore((s) => s.setSetting);
|
||||||
|
const docxCustomTemplatePath = useSettingsStore((s) => s.docxCustomTemplatePath);
|
||||||
|
const source = useExportSource();
|
||||||
|
|
||||||
|
const [templateMode, setTemplateMode] = useState<'standard' | 'custom'>(docxCustomTemplatePath ? 'custom' : 'standard');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTemplateMode(docxCustomTemplatePath ? 'custom' : 'standard');
|
||||||
|
}, [docxCustomTemplatePath]);
|
||||||
|
|
||||||
|
const handleChooseTemplate = async () => {
|
||||||
|
const result = await ipc.app.showSaveDialog?.({ title: 'Choose template path' });
|
||||||
|
if (result?.ok && result.data) {
|
||||||
|
setSetting('docxCustomTemplatePath', result.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!source) { setError('No file open.'); return; }
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const blob = await generateDocx({
|
||||||
|
source: source.source,
|
||||||
|
title: source.title,
|
||||||
|
customTemplatePath: templateMode === 'custom' ? docxCustomTemplatePath : null,
|
||||||
|
});
|
||||||
|
const saveResult = await ipc.app.showSaveDialog?.({ title: 'Save as Word document', defaultPath: source.path.replace(/\.md$/, '.docx') });
|
||||||
|
if (!saveResult?.ok || !saveResult.data) {
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buffer = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
|
||||||
|
if (!writeResult.ok) {
|
||||||
|
setError(writeResult.error.message);
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`Exported ${source.title} to ${saveResult.data}`);
|
||||||
|
closeModal();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error(`Export failed: ${msg}`);
|
||||||
|
setError(msg);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
|
<DialogContent aria-describedby="word-desc">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Export to Word (.docx)</DialogTitle>
|
||||||
|
<DialogDescription id="word-desc">{sourcePath}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<Label>Template</Label>
|
||||||
|
<RadioGroup value={templateMode} onValueChange={(v) => setTemplateMode(v as 'standard' | 'custom')}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem value="standard" id="template-standard" />
|
||||||
|
<Label htmlFor="template-standard">Standard (bundled)</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem value="custom" id="template-custom" />
|
||||||
|
<Label htmlFor="template-custom">Custom .dotx</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
{templateMode === 'custom' && (
|
||||||
|
<div className="rounded border border-border bg-card/20 p-2 text-xs">
|
||||||
|
{docxCustomTemplatePath ? (
|
||||||
|
<span>Template path: <code>{docxCustomTemplatePath}</code></span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No template selected. Click "Choose template..." to pick a .dotx file.</span>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleChooseTemplate} className="ml-2">
|
||||||
|
Choose template...
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? 'Exporting…' : 'Export'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { RefreshCw, FileX, FilePlus, FileEdit, FileQuestion } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
interface GitStatus {
|
||||||
|
filePath: string;
|
||||||
|
status: 'modified' | 'added' | 'deleted' | 'untracked';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<GitStatus['status'], JSX.Element> = {
|
||||||
|
modified: <FileEdit className="h-3 w-3 text-warning" />,
|
||||||
|
added: <FilePlus className="h-3 w-3 text-success" />,
|
||||||
|
deleted: <FileX className="h-3 w-3 text-destructive" />,
|
||||||
|
untracked: <FileQuestion className="h-3 w-3 text-muted-foreground" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<GitStatus['status'], string> = {
|
||||||
|
modified: 'M',
|
||||||
|
added: 'A',
|
||||||
|
deleted: 'D',
|
||||||
|
untracked: '?',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GitStatusPanel() {
|
||||||
|
const rootPath = useFileStore((s) => s.rootPath);
|
||||||
|
const openFile = useFileStore((s) => s.openFile);
|
||||||
|
const [status, setStatus] = useState<GitStatus[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!rootPath) {
|
||||||
|
setStatus([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await ipc.file.gitStatus({ rootPath });
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error.message);
|
||||||
|
setStatus([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(result.data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// Listen for git.refresh command via custom event
|
||||||
|
const handler = () => load();
|
||||||
|
window.addEventListener('mc:git-refresh', handler);
|
||||||
|
return () => window.removeEventListener('mc:git-refresh', handler);
|
||||||
|
}, [rootPath]);
|
||||||
|
|
||||||
|
if (!rootPath) {
|
||||||
|
return <div className="p-3 text-xs text-muted-foreground">No folder open</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 text-xs">
|
||||||
|
<div className="text-destructive">Error: {error}</div>
|
||||||
|
<p className="mt-1 text-muted-foreground">Not a git repository, or git not installed.</p>
|
||||||
|
<Button size="sm" variant="ghost" onClick={load} className="mt-2">Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.length === 0 && !loading) {
|
||||||
|
return <div className="p-3 text-xs text-muted-foreground">Working tree clean</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-2 text-xs">
|
||||||
|
<div className="flex items-center justify-between px-1 py-1">
|
||||||
|
<span className="font-semibold">{status.length} changed file{status.length === 1 ? '' : 's'}</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={load} aria-label="Refresh">
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{status.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.filePath}
|
||||||
|
onClick={() => openFile(s.filePath)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-card/50"
|
||||||
|
data-testid="git-status-row"
|
||||||
|
>
|
||||||
|
{STATUS_ICON[s.status]}
|
||||||
|
<span className="w-3 font-mono text-xs">{STATUS_LABEL[s.status]}</span>
|
||||||
|
<span className="truncate font-mono">{s.filePath.replace(rootPath + '/', '')}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
import { FileTree } from './FileTree';
|
import { FileTree } from './FileTree';
|
||||||
import { Outline } from './Outline';
|
import { Outline } from './Outline';
|
||||||
|
import { GitStatusPanel } from './GitStatusPanel';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const tree = useFileStore((s) => s.tree);
|
const tree = useFileStore((s) => s.tree);
|
||||||
@@ -49,6 +50,21 @@ export function Sidebar() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Git section */}
|
||||||
|
<Collapsible defaultOpen>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
|
||||||
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
|
Git
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||||
|
<GitStatusPanel />
|
||||||
|
</ScrollArea>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { X, Printer } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MarkdownRenderer } from '@/components/preview/MarkdownRenderer';
|
||||||
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrintPreview({ onClose }: Props) {
|
||||||
|
const source = useExportSource();
|
||||||
|
|
||||||
|
const handlePrint = async () => {
|
||||||
|
if (!source) return;
|
||||||
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${source.title}</title></head><body><pre>${source.source.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c] ?? c))}</pre></body></html>`;
|
||||||
|
await ipc.print({ html });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||||
|
<div className="flex h-12 items-center justify-between border-b border-border bg-card/30 px-4">
|
||||||
|
<h2 className="font-semibold">Print preview</h2>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
||||||
|
No file open
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||||
|
<div className="flex h-12 items-center justify-between border-b border-border bg-card/30 px-4">
|
||||||
|
<h2 className="font-semibold">Print preview — {source.title}</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
|
<Printer className="mr-2 h-4 w-4" />
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto bg-card/20 p-8">
|
||||||
|
<div className="mx-auto max-w-3xl rounded border border-border bg-background p-8 shadow-lg">
|
||||||
|
<MarkdownRenderer source={source.source} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { renderMarkdown } from '@/lib/markdown';
|
||||||
|
|
||||||
|
export function ReplPanel() {
|
||||||
|
const replOpen = useSettingsStore((s) => s.replOpen);
|
||||||
|
const setSetting = useSettingsStore((s) => s.setSetting);
|
||||||
|
const [source, setSource] = useState('# Markdown preview\n\nType here…');
|
||||||
|
const [debouncedSource, setDebouncedSource] = useState(source);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedSource(source), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
if (!replOpen) return null;
|
||||||
|
|
||||||
|
const html = renderMarkdown(debouncedSource);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="REPL"
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-40 flex h-[30vh] flex-col border-t border-border bg-card/95 shadow-2xl backdrop-blur"
|
||||||
|
data-testid="repl-panel"
|
||||||
|
>
|
||||||
|
<div className="flex h-8 items-center justify-between border-b border-border bg-card/50 px-3 text-xs">
|
||||||
|
<span className="font-semibold">REPL — Markdown snippet preview</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Close REPL"
|
||||||
|
onClick={() => setSetting('replOpen', false)}
|
||||||
|
className="h-6 w-6"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<Textarea
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value)}
|
||||||
|
className="h-full flex-1 resize-none rounded-none border-r border-border font-mono text-xs"
|
||||||
|
aria-label="Markdown source"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full flex-1 overflow-auto p-3 text-sm"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
data-testid="repl-preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
position="bottom-right"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts a global keydown listener that exits zen mode when the user
|
||||||
|
* presses Escape. Should be called once at the App root.
|
||||||
|
*/
|
||||||
|
export function useZenMode() {
|
||||||
|
const zenMode = useAppStore((s) => s.zenMode);
|
||||||
|
const setZenMode = useAppStore((s) => s.setZenMode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!zenMode) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setZenMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [zenMode, setZenMode]);
|
||||||
|
}
|
||||||
@@ -8,9 +8,15 @@ export function toAsciiTable(rows: string[][]): string {
|
|||||||
const widths: number[] = Array.from({ length: numCols }, (_, c) =>
|
const widths: number[] = Array.from({ length: numCols }, (_, c) =>
|
||||||
Math.max(...rows.map((r) => (r[c] ?? '').length))
|
Math.max(...rows.map((r) => (r[c] ?? '').length))
|
||||||
);
|
);
|
||||||
|
// Heuristic: if ANY column header starts with a digit, right-align ALL columns
|
||||||
|
const header = rows[0];
|
||||||
|
const rightAlign = header.some((cell) => /^\d/.test(cell));
|
||||||
|
const pad = rightAlign
|
||||||
|
? (s: string, w: number) => s.padStart(w)
|
||||||
|
: (s: string, w: number) => s.padEnd(w);
|
||||||
const lines = rows.map((row) =>
|
const lines = rows.map((row) =>
|
||||||
'| ' +
|
'| ' +
|
||||||
Array.from({ length: numCols }, (_, c) => (row[c] ?? '').padEnd(widths[c])).join(' | ') +
|
Array.from({ length: numCols }, (_, c) => pad(row[c] ?? '', widths[c])).join(' | ') +
|
||||||
' |'
|
' |'
|
||||||
);
|
);
|
||||||
// Insert separator after first row
|
// Insert separator after first row
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { useCommandStore } from '@/stores/command-store';
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useMenuAction } from '@/hooks/use-menu-action';
|
import { useMenuAction } from '@/hooks/use-menu-action';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +46,28 @@ export function registerMenuCommands(): void {
|
|||||||
'app.quit': () => {
|
'app.quit': () => {
|
||||||
/* stub — wired in later phase */
|
/* stub — wired in later phase */
|
||||||
},
|
},
|
||||||
|
'tools.ascii': () => useAppStore.getState().openModal('ascii-generator'),
|
||||||
|
'tools.table': () => useAppStore.getState().openModal('table-generator'),
|
||||||
|
'tools.findInFiles': () => useAppStore.getState().openModal('find-in-files'),
|
||||||
|
'tools.exportWord': () => {
|
||||||
|
const activeTabId = useFileStore.getState().activeTabId;
|
||||||
|
if (!activeTabId) return;
|
||||||
|
useAppStore.getState().openModal('export-word', { sourcePath: activeTabId });
|
||||||
|
},
|
||||||
|
'tools.repl': () => {
|
||||||
|
const current = useSettingsStore.getState().replOpen;
|
||||||
|
useSettingsStore.getState().setSetting('replOpen', !current);
|
||||||
|
},
|
||||||
|
'view.zenMode': () => {
|
||||||
|
const current = useAppStore.getState().zenMode;
|
||||||
|
useAppStore.getState().setZenMode(!current);
|
||||||
|
},
|
||||||
|
'file.print': () => {
|
||||||
|
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:print'));
|
||||||
|
},
|
||||||
|
'git.refresh': () => {
|
||||||
|
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:git-refresh'));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register } = useCommandStore.getState();
|
const { register } = useCommandStore.getState();
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx';
|
||||||
|
import { applyAsciiTransform } from './ascii-table';
|
||||||
|
|
||||||
|
export interface DocxOptions {
|
||||||
|
source: string;
|
||||||
|
title?: string;
|
||||||
|
customTemplatePath?: string | null; // for future use
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDocx(options: DocxOptions): Promise<Blob> {
|
||||||
|
// Apply ASCII transform (convert markdown tables to preformatted)
|
||||||
|
const transformed = applyAsciiTransform(options.source);
|
||||||
|
|
||||||
|
// Parse the source into a list of Paragraphs
|
||||||
|
// For v1, simple line-by-line: each line is a paragraph
|
||||||
|
// Headings (# ## ###) get heading styles
|
||||||
|
const lines = transformed.split('\n');
|
||||||
|
const children = lines.map((line) => {
|
||||||
|
if (line.startsWith('### ')) return new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 });
|
||||||
|
if (line.startsWith('## ')) return new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 });
|
||||||
|
if (line.startsWith('# ')) return new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 });
|
||||||
|
if (line.startsWith('```')) return new Paragraph({ text: line, alignment: AlignmentType.CENTER });
|
||||||
|
return new Paragraph({ children: [new TextRun(line)] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
sections: [{ children }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Packer.toBlob(doc);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import figlet from 'figlet';
|
||||||
|
import type { Fonts } from 'figlet';
|
||||||
|
|
||||||
|
export const FIGLET_FONTS = ['Standard', 'Big', 'Small', 'Banner', 'Doom', 'Slant', 'Block'] as const;
|
||||||
|
export type FigletFont = typeof FIGLET_FONTS[number];
|
||||||
|
|
||||||
|
export function figletText(text: string, font: FigletFont): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
figlet.text(text, { font }, (err, result) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(result ?? '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -76,7 +76,15 @@ export const ipc = {
|
|||||||
}
|
}
|
||||||
return window.electronAPI.file.onChange(cb);
|
return window.electronAPI.file.onChange(cb);
|
||||||
},
|
},
|
||||||
|
search: (args: { rootPath: string; query: string; isRegex: boolean; caseSensitive: boolean }): Promise<IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'search', args),
|
||||||
|
gitStatus: (args: { rootPath: string }): Promise<IpcResult<Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }> | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'gitStatus', args),
|
||||||
|
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'writeBuffer', args),
|
||||||
},
|
},
|
||||||
|
print: (args: { html: string }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('print', 'show', args),
|
||||||
export: {
|
export: {
|
||||||
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
safeCall('export', 'pdf', opts),
|
safeCall('export', 'pdf', opts),
|
||||||
@@ -94,6 +102,8 @@ export const ipc = {
|
|||||||
safeCall('app', 'openExternal', url),
|
safeCall('app', 'openExternal', url),
|
||||||
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
safeCall('app', 'showItemInFolder', path),
|
safeCall('app', 'showItemInFolder', path),
|
||||||
|
showSaveDialog: (args?: { title?: string; defaultPath?: string }): Promise<IpcResult<string | null | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'showSaveDialog', args),
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { toast as sonnerToast } from 'sonner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed wrappers over sonner's toast API. Centralizing the import gives us
|
||||||
|
* a single surface to evolve (e.g., add brand colors, action buttons,
|
||||||
|
* analytics) without touching every call site.
|
||||||
|
*/
|
||||||
|
export const toast = {
|
||||||
|
success: (message: string) => sonnerToast.success(message),
|
||||||
|
error: (message: string) => sonnerToast.error(message),
|
||||||
|
info: (message: string) => sonnerToast.info(message),
|
||||||
|
warning: (message: string) => sonnerToast.warning(message),
|
||||||
|
promise: <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
msgs: {
|
||||||
|
loading: string;
|
||||||
|
success: string | ((data: T) => string);
|
||||||
|
error: string | ((err: unknown) => string);
|
||||||
|
}
|
||||||
|
) => sonnerToast.promise(promise, msgs),
|
||||||
|
dismiss: (id?: string | number) => sonnerToast.dismiss(id),
|
||||||
|
};
|
||||||
@@ -13,6 +13,9 @@ export const settingsSchema = z.object({
|
|||||||
pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'),
|
pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'),
|
||||||
pdfEmbedFonts: z.boolean().default(true),
|
pdfEmbedFonts: z.boolean().default(true),
|
||||||
docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'),
|
docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'),
|
||||||
|
docxCustomTemplatePath: z.string().nullable().default(null),
|
||||||
|
replOpen: z.boolean().default(false),
|
||||||
|
breadcrumbSymbols: z.boolean().default(true),
|
||||||
htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'),
|
htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'),
|
||||||
renderTablesAsAscii: z.boolean().default(false),
|
renderTablesAsAscii: z.boolean().default(false),
|
||||||
welcomeDismissed: z.boolean().default(false),
|
welcomeDismissed: z.boolean().default(false),
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export type ModalState =
|
|||||||
| { kind: 'settings' }
|
| { kind: 'settings' }
|
||||||
| { kind: 'about' }
|
| { kind: 'about' }
|
||||||
| { kind: 'welcome' }
|
| { kind: 'welcome' }
|
||||||
| { kind: 'confirm'; props: ConfirmProps };
|
| { kind: 'confirm'; props: ConfirmProps }
|
||||||
|
| { kind: 'export-word'; props: { sourcePath: string } }
|
||||||
|
| { kind: 'ascii-generator' }
|
||||||
|
| { kind: 'table-generator' }
|
||||||
|
| { kind: 'find-in-files' };
|
||||||
|
|
||||||
export type ModalKind = ModalState['kind'];
|
export type ModalKind = ModalState['kind'];
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
|||||||
import { immer } from 'zustand/middleware/immer';
|
import { immer } from 'zustand/middleware/immer';
|
||||||
import { enableMapSet } from 'immer';
|
import { enableMapSet } from 'immer';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
import type { FileEntry } from '@/types/ipc';
|
import type { FileEntry } from '@/types/ipc';
|
||||||
|
|
||||||
@@ -79,7 +80,10 @@ export const useFileStore = create<FileState>()(
|
|||||||
|
|
||||||
openFolder: async (path) => {
|
openFolder: async (path) => {
|
||||||
const result = await ipc.file.list(path);
|
const result = await ipc.file.list(path);
|
||||||
if (!result.ok) return;
|
if (!result.ok) {
|
||||||
|
toast.error(`Failed to open folder: ${result.error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const children: FileNode[] = result.data!.map(entryToNode);
|
const children: FileNode[] = result.data!.map(entryToNode);
|
||||||
|
|
||||||
@@ -144,7 +148,10 @@ export const useFileStore = create<FileState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await ipc.file.read(filePath);
|
const result = await ipc.file.read(filePath);
|
||||||
if (!result.ok) return;
|
if (!result.ok) {
|
||||||
|
toast.error(`Failed to open file: ${result.error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const content = result.data!;
|
const content = result.data!;
|
||||||
const title = filePath.split('/').pop() ?? filePath;
|
const title = filePath.split('/').pop() ?? filePath;
|
||||||
@@ -217,10 +224,15 @@ export const useFileStore = create<FileState>()(
|
|||||||
if (!buffer) return false;
|
if (!buffer) return false;
|
||||||
|
|
||||||
const writeResult = await ipc.file.write(activeTabId, buffer.content);
|
const writeResult = await ipc.file.write(activeTabId, buffer.content);
|
||||||
if (!writeResult.ok) return false;
|
if (!writeResult.ok) {
|
||||||
|
toast.error(`Failed to save: ${writeResult.error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
useEditorStore.getState().markSaved(activeTabId);
|
useEditorStore.getState().markSaved(activeTabId);
|
||||||
useFileStore.getState().markTabClean(activeTabId);
|
useFileStore.getState().markTabClean(activeTabId);
|
||||||
|
const title = useFileStore.getState().openTabs.find(t => t.id === activeTabId)?.title ?? activeTabId.split('/').pop() ?? activeTabId;
|
||||||
|
toast.success(`Saved ${title}`);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Minimap } from '@/components/editor/Minimap';
|
||||||
|
|
||||||
|
describe('Minimap', () => {
|
||||||
|
it('renders lines of content as shrunk text', () => {
|
||||||
|
const content = 'line 1\nline 2\nline 3';
|
||||||
|
render(<Minimap content={content} />);
|
||||||
|
const lines = screen.getAllByTestId('minimap-line');
|
||||||
|
expect(lines).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a viewport indicator', () => {
|
||||||
|
render(
|
||||||
|
<Minimap content={'line 1\nline 2\nline 3'} scrollRatio={0.5} visibleRatio={0.5} />
|
||||||
|
);
|
||||||
|
const indicator = screen.getByTestId('minimap-viewport');
|
||||||
|
expect(indicator).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Breadcrumb } from '@/components/layout/Breadcrumb';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
describe('Breadcrumb', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), breadcrumbSymbols: true });
|
||||||
|
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
||||||
|
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# H1\n## H2', dirty: false }]]) } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No file selected" when no tab is active', () => {
|
||||||
|
useFileStore.setState({ activeTabId: null, openTabs: [] } as any);
|
||||||
|
render(<Breadcrumb />);
|
||||||
|
expect(screen.getByText('No file selected')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows tab title when a tab is active', () => {
|
||||||
|
render(<Breadcrumb />);
|
||||||
|
expect(screen.getByText('test.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows heading symbols when breadcrumbSymbols is true', () => {
|
||||||
|
render(<Breadcrumb />);
|
||||||
|
expect(screen.getByText('test.md')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/# H1/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show heading symbols when breadcrumbSymbols is false', () => {
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), breadcrumbSymbols: false });
|
||||||
|
render(<Breadcrumb />);
|
||||||
|
expect(screen.getByText('test.md')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/# H1/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits heading symbols to 3', () => {
|
||||||
|
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
||||||
|
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# H1\n## H2\n### H3\n#### H4', dirty: false }]]) } as any);
|
||||||
|
render(<Breadcrumb />);
|
||||||
|
// Should show H1, H2, H3 but not H4
|
||||||
|
expect(screen.getByText(/# H1/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/# H2/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/# H3/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/# H4/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { AsciiGeneratorDialog } from '@/components/modals/AsciiGeneratorDialog';
|
||||||
|
|
||||||
|
vi.mock('@/lib/figlet', () => ({
|
||||||
|
figletText: vi.fn().mockResolvedValue(' MOCKED ASCII '),
|
||||||
|
FIGLET_FONTS: ['Standard', 'Big', 'Small', 'Banner', 'Doom', 'Slant', 'Block'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AsciiGeneratorDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with an input textarea and font selector', () => {
|
||||||
|
render(<AsciiGeneratorDialog />);
|
||||||
|
expect(screen.getByText(/ascii generator/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('combobox', { name: /font/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox', { name: /text/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the ASCII output as the user types', async () => {
|
||||||
|
render(<AsciiGeneratorDialog />);
|
||||||
|
const input = screen.getByRole('textbox', { name: /text/i });
|
||||||
|
await userEvent.type(input, 'hi');
|
||||||
|
const pre = await screen.findByTestId('ascii-output');
|
||||||
|
expect(pre).toHaveTextContent('MOCKED ASCII');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a copy button next to the output', () => {
|
||||||
|
render(<AsciiGeneratorDialog />);
|
||||||
|
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ describe('ExportPdfDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders an error banner when IPC fails', async () => {
|
it('renders an error banner when IPC fails', async () => {
|
||||||
(window.electronAPI.export.pdf as any).mockResolvedValueOnce({ ok: false, error: { code: 'E', message: 'Pandoc not found' } });
|
(window.electronAPI.export.pdf as any).mockRejectedValueOnce(new Error('Pandoc not found'));
|
||||||
render(<ExportPdfDialog sourcePath="/test.md" />);
|
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
|
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { FindInFilesDialog } from '@/components/modals/FindInFilesDialog';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
search: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
describe('FindInFilesDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useFileStore.setState({ rootPath: '/project' } as any);
|
||||||
|
useAppStore.setState({ modal: { kind: null } } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with a query input and toggles', () => {
|
||||||
|
render(<FindInFilesDialog />);
|
||||||
|
expect(screen.getByText(/find in files/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox', { name: /query/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /regex/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /case/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits a search and shows results', async () => {
|
||||||
|
(ipc.file.search as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: [
|
||||||
|
{ filePath: '/project/a.md', line: 5, content: 'matching line' },
|
||||||
|
{ filePath: '/project/b.md', line: 12, content: 'another match' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<FindInFilesDialog />);
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /query/i }), 'match');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /search/i }));
|
||||||
|
expect(await screen.findByText('/project/a.md:5')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/another match/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a result calls useFileStore.openFile', async () => {
|
||||||
|
(ipc.file.search as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: [{ filePath: '/project/a.md', line: 5, content: 'matching line' }],
|
||||||
|
});
|
||||||
|
const openFileSpy = vi.fn();
|
||||||
|
useFileStore.setState({ openFile: openFileSpy, rootPath: '/project' } as any);
|
||||||
|
render(<FindInFilesDialog />);
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /query/i }), 'match');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /search/i }));
|
||||||
|
const result = await screen.findByText('/project/a.md:5');
|
||||||
|
await userEvent.click(result);
|
||||||
|
// openFile is called with the filePath; the test verifies it was called
|
||||||
|
expect(openFileSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error banner when search fails', async () => {
|
||||||
|
(ipc.file.search as any).mockResolvedValueOnce({ ok: false, error: { code: 'E', message: 'regex invalid' } });
|
||||||
|
render(<FindInFilesDialog />);
|
||||||
|
await userEvent.paste('[invalid', { initialSelectionStart: 0, initialSelectionEnd: 0 });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /search/i }));
|
||||||
|
expect(await screen.findByText(/regex invalid/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { TableGeneratorDialog } from '@/components/modals/TableGeneratorDialog';
|
||||||
|
|
||||||
|
describe('TableGeneratorDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with rows, cols, and header inputs', () => {
|
||||||
|
render(<TableGeneratorDialog />);
|
||||||
|
expect(screen.getByText(/table generator/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('spinbutton', { name: /rows/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('spinbutton', { name: /cols/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /header/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a markdown table on rows/cols change', async () => {
|
||||||
|
render(<TableGeneratorDialog />);
|
||||||
|
const rowsInput = screen.getByRole('spinbutton', { name: /rows/i });
|
||||||
|
const colsInput = screen.getByRole('spinbutton', { name: /cols/i });
|
||||||
|
await userEvent.clear(rowsInput);
|
||||||
|
await userEvent.type(rowsInput, '2');
|
||||||
|
await userEvent.clear(colsInput);
|
||||||
|
await userEvent.type(colsInput, '3');
|
||||||
|
// Output should have header + separator + 2 rows = 4 lines for a 3-col table with header
|
||||||
|
const output = screen.getByTestId('table-output');
|
||||||
|
expect(output.textContent).toContain('|');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a copy button next to the output', () => {
|
||||||
|
render(<TableGeneratorDialog />);
|
||||||
|
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { WordExportDialog } from '@/components/modals/WordExportDialog';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/docx-export', () => ({
|
||||||
|
generateDocx: vi.fn().mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/toast', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
app: {
|
||||||
|
showSaveDialog: vi.fn(),
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
writeBuffer: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { generateDocx } from '@/lib/docx-export';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
describe('WordExportDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||||
|
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
||||||
|
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
|
||||||
|
(ipc.app.showSaveDialog as any).mockResolvedValue({ ok: true, data: '/out.docx' });
|
||||||
|
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with template selector (standard / custom)', () => {
|
||||||
|
render(<WordExportDialog sourcePath="/test.md" />);
|
||||||
|
expect(screen.getByText(/export to word/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/test\.md/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submitting calls generateDocx and writes the file', async () => {
|
||||||
|
render(<WordExportDialog sourcePath="/test.md" />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
|
expect(generateDocx).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Exported test.md'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error message on write failure', async () => {
|
||||||
|
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: false, error: { code: 'E', message: 'write failed' } });
|
||||||
|
render(<WordExportDialog sourcePath="/test.md" />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
|
expect(await screen.findByText(/write failed/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to custom template path when "Custom" is selected', async () => {
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), docxCustomTemplatePath: '/my.dotx' });
|
||||||
|
render(<WordExportDialog sourcePath="/test.md" />);
|
||||||
|
expect(screen.getByText(/\/my\.dotx/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { GitStatusPanel } from '@/components/sidebar/GitStatusPanel';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
gitStatus: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
describe('GitStatusPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No folder open" when rootPath is null', () => {
|
||||||
|
useFileStore.setState({ rootPath: null } as any);
|
||||||
|
render(<GitStatusPanel />);
|
||||||
|
expect(screen.getByText(/no folder open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches and shows git status', async () => {
|
||||||
|
(ipc.file.gitStatus as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: [
|
||||||
|
{ filePath: '/project/a.md', status: 'modified' },
|
||||||
|
{ filePath: '/project/b.md', status: 'added' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
useFileStore.setState({ rootPath: '/project' } as any);
|
||||||
|
render(<GitStatusPanel />);
|
||||||
|
expect(await screen.findByText('a.md')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('b.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Working tree clean" when no changes', async () => {
|
||||||
|
(ipc.file.gitStatus as any).mockResolvedValueOnce({ ok: true, data: [] });
|
||||||
|
useFileStore.setState({ rootPath: '/project' } as any);
|
||||||
|
render(<GitStatusPanel />);
|
||||||
|
expect(await screen.findByText(/working tree clean/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state when gitStatus fails', async () => {
|
||||||
|
(ipc.file.gitStatus as any).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'IPC_ERROR', message: 'Not a git repository' },
|
||||||
|
});
|
||||||
|
useFileStore.setState({ rootPath: '/project' } as any);
|
||||||
|
render(<GitStatusPanel />);
|
||||||
|
// The helper text appears in a <p> element distinct from the error heading
|
||||||
|
expect(await screen.findByText('Not a git repository, or git not installed.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens file on click', async () => {
|
||||||
|
const openFile = vi.fn();
|
||||||
|
(ipc.file.gitStatus as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: [{ filePath: '/project/a.md', status: 'modified' }],
|
||||||
|
});
|
||||||
|
useFileStore.setState({ rootPath: '/project', openFile } as any);
|
||||||
|
render(<GitStatusPanel />);
|
||||||
|
const row = await screen.findByTestId('git-status-row');
|
||||||
|
await userEvent.click(row);
|
||||||
|
expect(openFile).toHaveBeenCalledWith('/project/a.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ vi.mock('@/lib/ipc', () => ({
|
|||||||
pickFile: vi.fn(),
|
pickFile: vi.fn(),
|
||||||
read: vi.fn(),
|
read: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
|
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { PrintPreview } from '@/components/tools/PrintPreview';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
print: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
describe('PrintPreview', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useFileStore.setState({
|
||||||
|
activeTabId: '/test.md',
|
||||||
|
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
|
||||||
|
} as any);
|
||||||
|
useEditorStore.setState({
|
||||||
|
buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]),
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the buffer content', () => {
|
||||||
|
render(<PrintPreview onClose={() => {}} />);
|
||||||
|
expect(screen.getByRole('heading', { name: /hi/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /print/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Print button calls ipc.print', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PrintPreview onClose={onClose} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /print/i }));
|
||||||
|
expect(ipc.print).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Close button calls onClose', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PrintPreview onClose={onClose} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ReplPanel } from '@/components/tools/ReplPanel';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
describe('ReplPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), replOpen: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the textarea and preview when replOpen is true', () => {
|
||||||
|
render(<ReplPanel />);
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/repl/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when replOpen is false', () => {
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), replOpen: false });
|
||||||
|
const { container } = render(<ReplPanel />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('typing in the textarea updates the preview', async () => {
|
||||||
|
render(<ReplPanel />);
|
||||||
|
const ta = screen.getByRole('textbox');
|
||||||
|
await userEvent.clear(ta);
|
||||||
|
await userEvent.type(ta, '# Hello');
|
||||||
|
// Wait for 300ms debounce to fire
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
expect(screen.getByRole('heading', { name: /hello/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
|
||||||
|
describe('Toaster', () => {
|
||||||
|
it('renders inside ThemeProvider without crashing', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ThemeProvider defaultTheme="light" attribute="class">
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
// The Toaster portal is mounted at body level, so the container may be empty
|
||||||
|
// but the test should not throw
|
||||||
|
expect(container).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ vi.mock('@/lib/ipc', () => ({
|
|||||||
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
||||||
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
||||||
onChange: vi.fn(() => () => {}),
|
onChange: vi.fn(() => () => {}),
|
||||||
|
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
on: vi.fn(() => () => {}),
|
on: vi.fn(() => () => {}),
|
||||||
@@ -54,6 +55,8 @@ describe('Phase 5 integration', () => {
|
|||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
});
|
});
|
||||||
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
// Also clear the active buffer so breadcrumb symbols won't show stale heading text
|
||||||
|
useEditorStore.getState().buffers.clear();
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
sidebarVisible: true,
|
sidebarVisible: true,
|
||||||
previewVisible: true,
|
previewVisible: true,
|
||||||
@@ -128,12 +131,14 @@ describe('Phase 5 integration', () => {
|
|||||||
],
|
],
|
||||||
activeTabId: '/a.md',
|
activeTabId: '/a.md',
|
||||||
});
|
});
|
||||||
|
// Use a buffer content that won't conflict with tab title in breadcrumb symbols
|
||||||
|
useEditorStore.setState({ buffers: new Map([['/a.md', { id: '/a.md', path: '/a.md', content: '# Hello', dirty: false }], ['/b.md', { id: '/b.md', path: '/b.md', content: '# World', dirty: false }]]), activeId: '/a.md' });
|
||||||
render(<AppShell />);
|
render(<AppShell />);
|
||||||
const aTab = screen.getByText('a.md').closest('[role="tab"]')!;
|
// Use role="tab" to find tabs specifically, avoiding breadcrumb "a.md" text
|
||||||
const bTab = screen.getByText('b.md').closest('[role="tab"]')!;
|
const tabs = screen.getAllByRole('tab');
|
||||||
expect(aTab).toHaveAttribute('aria-current', 'page');
|
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(bTab);
|
fireEvent.click(tabs[1]);
|
||||||
});
|
});
|
||||||
expect(useFileStore.getState().activeTabId).toBe('/b.md');
|
expect(useFileStore.getState().activeTabId).toBe('/b.md');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ vi.mock('@/lib/ipc', () => ({
|
|||||||
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
||||||
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
||||||
onChange: vi.fn(() => () => {}),
|
onChange: vi.fn(() => () => {}),
|
||||||
|
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => {
|
on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@/lib/toast', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
promise: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
app: {
|
||||||
|
getVersion: vi.fn().mockResolvedValue({ ok: true, data: '5.0.0' }),
|
||||||
|
openExternal: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
read: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
pickFolder: vi.fn(),
|
||||||
|
pickFile: vi.fn(),
|
||||||
|
onChange: vi.fn(),
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
on: vi.fn(() => () => {}),
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
pdf: vi.fn(),
|
||||||
|
docx: vi.fn(),
|
||||||
|
html: vi.fn(),
|
||||||
|
batch: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import App from '@/App';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
import { registerMenuCommands } from '@/lib/commands/register-menu-commands';
|
||||||
|
|
||||||
|
describe('Phase 8 toasts integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppStore.setState({ modal: { kind: null }, sidebarVisible: true, previewVisible: true, zenMode: false, paneSizes: { sidebar: 20, editor: 50, preview: 30 } } as any);
|
||||||
|
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), welcomeDismissed: true });
|
||||||
|
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
||||||
|
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: true }]]) } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saving a file calls toast.success with "Saved test.md"', async () => {
|
||||||
|
(ipc.file.write as any).mockResolvedValue({ ok: true });
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Saved test.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saving with IPC error calls toast.error', async () => {
|
||||||
|
(ipc.file.write as any).mockResolvedValue({ ok: false, error: { code: 'EACCES', message: 'Permission denied' } });
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to save: Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening a missing file calls toast.error', async () => {
|
||||||
|
(ipc.file.read as any).mockResolvedValue({ ok: false, error: { code: 'ENOENT', message: 'No such file' } });
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await useFileStore.getState().openFile('/missing.md');
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to open file: No such file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exporting a file calls toast.success on success', async () => {
|
||||||
|
(ipc.export.pdf as any).mockResolvedValue({ ok: true, data: { outputPath: '/test.pdf', bytes: 100, durationMs: 50 } });
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// Open the export-pdf modal via command dispatch
|
||||||
|
useCommandStore.getState().dispatch('file.exportPdf');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'export-pdf', props: { sourcePath: '/test.md' } });
|
||||||
|
|
||||||
|
// Click the Export button
|
||||||
|
const exportBtn = await screen.findByRole('button', { name: /^export$/i });
|
||||||
|
await userEvent.click(exportBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Exported test.md'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { registerMenuCommands } from '@/lib/commands/register-menu-commands';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
describe('Phase 9 commands', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
|
||||||
|
useAppStore.setState({ modal: { kind: null } } as any);
|
||||||
|
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
|
||||||
|
// Use resetToDefaults to restore store methods (setSetting/resetToDefaults)
|
||||||
|
useSettingsStore.getState().resetToDefaults();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.ascii opens ascii-generator modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.ascii');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'ascii-generator' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.table opens table-generator modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.table');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'table-generator' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.findInFiles opens find-in-files modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.findInFiles');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'find-in-files' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.exportWord opens export-word modal with active path', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.exportWord');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'export-word', props: { sourcePath: '/x.md' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.repl toggles replOpen setting', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(false);
|
||||||
|
useCommandStore.getState().dispatch('tools.repl');
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(true);
|
||||||
|
useCommandStore.getState().dispatch('tools.repl');
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { useZenMode } from '@/hooks/use-zen-mode';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
describe('useZenMode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({ ...useAppStore.getInitialState(), zenMode: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches a keydown listener that exits zen mode on Escape', () => {
|
||||||
|
renderHook(() => useZenMode());
|
||||||
|
expect(useAppStore.getState().zenMode).toBe(true);
|
||||||
|
|
||||||
|
// Simulate Escape keydown
|
||||||
|
const event = new KeyboardEvent('keydown', { key: 'Escape' });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(useAppStore.getState().zenMode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if zen mode is already off', () => {
|
||||||
|
useAppStore.setState({ ...useAppStore.getInitialState(), zenMode: false });
|
||||||
|
const setZenMode = vi.fn();
|
||||||
|
useAppStore.setState({ setZenMode });
|
||||||
|
renderHook(() => useZenMode());
|
||||||
|
// No Escape dispatched; setZenMode should not be called
|
||||||
|
expect(setZenMode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateDocx } from '@/lib/docx-export';
|
||||||
|
|
||||||
|
describe('generateDocx', () => {
|
||||||
|
it('returns a Blob for a simple markdown string', async () => {
|
||||||
|
const blob = await generateDocx({ source: '# Hello\n\nWorld' });
|
||||||
|
expect(blob).toBeInstanceOf(Blob);
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
expect(blob.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts headings to docx heading styles', async () => {
|
||||||
|
const blob = await generateDocx({ source: '# H1\n## H2\n### H3' });
|
||||||
|
// The blob should be a valid zip-based docx; check size and MIME
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts markdown tables to preformatted (via applyAsciiTransform)', async () => {
|
||||||
|
const source = '| A | B |\n| - | - |\n| 1 | 2 |';
|
||||||
|
const blob = await generateDocx({ source });
|
||||||
|
expect(blob).toBeInstanceOf(Blob);
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
promise: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
import { toast as sonnerToast } from 'sonner';
|
||||||
|
|
||||||
|
describe('toast helpers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toast.success forwards to sonner with the message', () => {
|
||||||
|
toast.success('Saved test.md');
|
||||||
|
expect(sonnerToast.success).toHaveBeenCalledWith('Saved test.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toast.error forwards to sonner with the message', () => {
|
||||||
|
toast.error('Failed to save: ENOENT');
|
||||||
|
expect(sonnerToast.error).toHaveBeenCalledWith('Failed to save: ENOENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toast.info forwards to sonner with the message', () => {
|
||||||
|
toast.info('Update available');
|
||||||
|
expect(sonnerToast.info).toHaveBeenCalledWith('Update available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toast.warning forwards to sonner with the message', () => {
|
||||||
|
toast.warning('Disk space low');
|
||||||
|
expect(sonnerToast.warning).toHaveBeenCalledWith('Disk space low');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toast.promise forwards the promise and messages to sonner', async () => {
|
||||||
|
const promise = Promise.resolve('ok');
|
||||||
|
const msgs = { loading: 'Loading…', success: 'Done', error: 'Failed' };
|
||||||
|
toast.promise(promise, msgs);
|
||||||
|
expect(sonnerToast.promise).toHaveBeenCalledWith(promise, msgs);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
list: vi.fn(),
|
||||||
|
read: vi.fn(),
|
||||||
|
pickFolder: vi.fn(),
|
||||||
|
pickFile: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/toast', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
promise: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
const fakeRead = ipc.file.read as ReturnType<typeof vi.fn>;
|
||||||
|
const fakeWrite = ipc.file.write as ReturnType<typeof vi.fn>;
|
||||||
|
const fakeList = ipc.file.list as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
describe('file-store toasts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set<string>(),
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
} as any);
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveActiveBuffer', () => {
|
||||||
|
it('calls toast.success when save succeeds', async () => {
|
||||||
|
fakeWrite.mockResolvedValue({ ok: true, data: undefined });
|
||||||
|
useFileStore.setState({
|
||||||
|
activeTabId: '/test.md',
|
||||||
|
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
|
||||||
|
} as any);
|
||||||
|
useEditorStore.setState({
|
||||||
|
buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: true }]]),
|
||||||
|
activeId: '/test.md',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Saved test.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls toast.error when save fails', async () => {
|
||||||
|
fakeWrite.mockResolvedValue({ ok: false, error: { code: 'EACCES', message: 'Permission denied' } });
|
||||||
|
useFileStore.setState({
|
||||||
|
activeTabId: '/test.md',
|
||||||
|
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
|
||||||
|
} as any);
|
||||||
|
useEditorStore.setState({
|
||||||
|
buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: true }]]),
|
||||||
|
activeId: '/test.md',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to save: Permission denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('openFile', () => {
|
||||||
|
it('calls toast.error when IPC read fails', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: false, error: { code: 'ENOENT', message: 'No such file' } });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFile('/missing.md');
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to open file: No such file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('openFolder', () => {
|
||||||
|
it('calls toast.error when IPC list fails', async () => {
|
||||||
|
fakeList.mockResolvedValue({ ok: false, error: { code: 'EACCES', message: 'Permission denied' } });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFolder('/forbidden');
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to open folder: Permission denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user