figure-viewer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/commands/figures.md +17 -0
- package/README.md +99 -0
- package/figure-viewer-spec.md +293 -0
- package/figure-viewer.config.json +12 -0
- package/figure-viewer.js +186 -0
- package/lib/config.js +44 -0
- package/lib/discover.js +54 -0
- package/lib/history.js +130 -0
- package/lib/html.js +486 -0
- package/lib/watcher-daemon.js +96 -0
- package/lib/watcher.js +43 -0
- package/package.json +33 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Open figure viewer for the current project
|
|
3
|
+
agent: build
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run the figure viewer to check generated figures:
|
|
7
|
+
|
|
8
|
+
!`figure-viewer --watch`
|
|
9
|
+
|
|
10
|
+
This will:
|
|
11
|
+
- Auto-discover the figures directory (outputs/figures/, figures/, or plots/)
|
|
12
|
+
- Open a browser split below in cmux
|
|
13
|
+
- Watch for changes in the background
|
|
14
|
+
|
|
15
|
+
To stop the watcher when done:
|
|
16
|
+
|
|
17
|
+
!`figure-viewer --kill`
|
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Figure Viewer
|
|
2
|
+
|
|
3
|
+
CLI tool for viewing and navigating scientific figures in a browser pane.
|
|
4
|
+
|
|
5
|
+
**Note:** This project is not affiliated with or endorsed by OpenCode or cmux. It simply integrates with their tools.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Auto-discovers figures directories (`outputs/figures`, `figures`, `plots`, etc.)
|
|
10
|
+
- Interactive HTML viewer with grid layout
|
|
11
|
+
- Freshness indicators (Active/Recent/Older)
|
|
12
|
+
- Lightbox for full-size image viewing
|
|
13
|
+
- File watching with auto-refresh
|
|
14
|
+
- cmux browser integration (opens in split below)
|
|
15
|
+
- Configurable via `.figure-viewer.json`
|
|
16
|
+
- Multiple independent sessions support
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g figure-viewer
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or link locally:
|
|
25
|
+
```bash
|
|
26
|
+
cd figure-viewer
|
|
27
|
+
npm link
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Basic - auto-discover figures directory
|
|
34
|
+
figure-viewer --watch
|
|
35
|
+
|
|
36
|
+
# With options
|
|
37
|
+
figure-viewer --watch --refresh 60 --timeout 120
|
|
38
|
+
|
|
39
|
+
# Kill watcher when done
|
|
40
|
+
figure-viewer --kill
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Options
|
|
44
|
+
|
|
45
|
+
| Option | Description | Default |
|
|
46
|
+
|--------|-------------|---------|
|
|
47
|
+
| `-p, --path <path>` | Explicit path to figures directory | auto-discover |
|
|
48
|
+
| `-o, --output <path>` | Output HTML file path | `figure-viewer.html` |
|
|
49
|
+
| `-w, --watch` | Watch for file changes | false |
|
|
50
|
+
| `--no-open` | Don't open browser | false |
|
|
51
|
+
| `-r, --refresh <seconds>` | Auto-refresh interval | 30 |
|
|
52
|
+
| `-t, --timeout <minutes>` | Kill after inactivity | 60 |
|
|
53
|
+
| `--kill` | Kill background watcher | - |
|
|
54
|
+
| `--clear-history` | Clear figure history | - |
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
Create a `.figure-viewer.json` in your project or home directory:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"figuresDirs": [
|
|
63
|
+
"outputs/figures",
|
|
64
|
+
"figures",
|
|
65
|
+
"plots",
|
|
66
|
+
"output/figures",
|
|
67
|
+
"output/plots"
|
|
68
|
+
],
|
|
69
|
+
"refreshInterval": 30000,
|
|
70
|
+
"watcherTimeout": 3600000,
|
|
71
|
+
"openOnStartup": true
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## OpenCode Integration
|
|
76
|
+
|
|
77
|
+
Add `/figures` command to OpenCode:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Link the command
|
|
81
|
+
ln -s /path/to/figure-viewer/.opencode/command/figures.md ~/.config/opencode/command/figures.md
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then use in OpenCode:
|
|
85
|
+
```
|
|
86
|
+
/figures
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## How It Works
|
|
90
|
+
|
|
91
|
+
1. Auto-discovers figures directory by walking up from current directory
|
|
92
|
+
2. Generates interactive HTML with figure grid
|
|
93
|
+
3. Opens in cmux browser split (or falls back to system browser)
|
|
94
|
+
4. Watches for file changes in background
|
|
95
|
+
5. Auto-kills after inactivity timeout (configurable)
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Figure Viewer — Specification
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
A CLI tool for viewing and navigating scientific figures generated during agentic data analysis workflows. Designed for macOS + cmux + OpenCode. Renders figures in a browser pane within cmux for perfect image quality.
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
When AI coding agents (OpenCode) generate figures during data analysis:
|
|
10
|
+
- Opening figures requires context-switching to Finder/Preview
|
|
11
|
+
- No good TUI-native solution for image preview on macOS
|
|
12
|
+
- Hard to track which figures are new vs. iterations
|
|
13
|
+
- Want to view figures inside cmux without leaving the terminal
|
|
14
|
+
|
|
15
|
+
## Solution
|
|
16
|
+
|
|
17
|
+
- CLI tool: `figure-viewer`
|
|
18
|
+
- Generates interactive HTML from figures directory
|
|
19
|
+
- HTML opened in cmux browser split
|
|
20
|
+
- Auto-discovery of figures folder from working directory
|
|
21
|
+
- Freshness tracking for iteration awareness
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## CLI Interface
|
|
26
|
+
|
|
27
|
+
### Commands
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Basic usage — auto-discover figures from cwd
|
|
31
|
+
figure-viewer
|
|
32
|
+
|
|
33
|
+
# Explicit path
|
|
34
|
+
figure-viewer ./outputs/figures
|
|
35
|
+
figure-viewer /path/to/project/analysis/outputs/figures
|
|
36
|
+
|
|
37
|
+
# Watch mode (default on)
|
|
38
|
+
figure-viewer --watch
|
|
39
|
+
figure-viewer --watch ./figures
|
|
40
|
+
|
|
41
|
+
# Disable auto-open in browser
|
|
42
|
+
figure-viewer --no-open
|
|
43
|
+
|
|
44
|
+
# Specify output HTML path
|
|
45
|
+
figure-viewer --output viewer.html
|
|
46
|
+
|
|
47
|
+
# Help
|
|
48
|
+
figure-viewer --help
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Auto-Discovery Logic
|
|
52
|
+
|
|
53
|
+
1. If path provided, use it
|
|
54
|
+
2. Else, start at `pwd`
|
|
55
|
+
3. Walk up directories looking for:
|
|
56
|
+
- `outputs/figures/`
|
|
57
|
+
- `figures/`
|
|
58
|
+
- `plots/`
|
|
59
|
+
- `output/figures/`
|
|
60
|
+
4. If not found, error with suggestion to pass explicit path
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## HTML Viewer
|
|
65
|
+
|
|
66
|
+
### Layout
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
┌─────────────────────────────────────────────────────┐
|
|
70
|
+
│ 🔄 Figure Viewer [Refresh] │
|
|
71
|
+
├─────────────────────────────────────────────────────┤
|
|
72
|
+
│ Filter: [________________] Sort: [Newest ▼] │
|
|
73
|
+
├─────────────────────────────────────────────────────┤
|
|
74
|
+
│ │
|
|
75
|
+
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
|
76
|
+
│ │ img │ │ img │ │ img │ │ img │ │
|
|
77
|
+
│ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │
|
|
78
|
+
│ │ 🔴 │ │ ⚪ │ │ ⚪ │ │ 🟡 │ │
|
|
79
|
+
│ └──────┘ └──────┘ └──────┘ └──────┘ │
|
|
80
|
+
│ │
|
|
81
|
+
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
|
82
|
+
│ │ img │ │ img │ │ img │ │ img │ │
|
|
83
|
+
│ │ 5 │ │ 6 │ │ 7 │ │ 8 │ │
|
|
84
|
+
│ │ ⚪ │ │ ⚪ │ │ 🔴 │ │ ⚪ │ │
|
|
85
|
+
│ └──────┘ └──────┘ └──────┘ └──────┘ │
|
|
86
|
+
│ │
|
|
87
|
+
└─────────────────────────────────────────────────────┘
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Grid
|
|
91
|
+
|
|
92
|
+
- Responsive grid: auto-fit, min 200px per image
|
|
93
|
+
- Each card shows:
|
|
94
|
+
- Thumbnail (aspect-ratio preserved)
|
|
95
|
+
- Filename (truncated if long)
|
|
96
|
+
- Timestamp (relative: "2 min ago", "1 hour ago")
|
|
97
|
+
- Freshness indicator (color border)
|
|
98
|
+
|
|
99
|
+
### Freshness Indicators
|
|
100
|
+
|
|
101
|
+
| Status | Color | Time Window |
|
|
102
|
+
|--------|-------|-------------|
|
|
103
|
+
| 🔴 Active | Red border | < 5 minutes |
|
|
104
|
+
| 🟡 Recent | Yellow border | 5 min – 1 hour |
|
|
105
|
+
| ⚪ Older | Gray border | > 1 hour |
|
|
106
|
+
|
|
107
|
+
### Lightbox
|
|
108
|
+
|
|
109
|
+
On click:
|
|
110
|
+
- Full-size image in modal overlay
|
|
111
|
+
- Dark background (rgba(0,0,0,0.9))
|
|
112
|
+
- Close: ESC key or click outside
|
|
113
|
+
- Navigation: ← → arrows or keyboard
|
|
114
|
+
- Shows metadata: filename, dimensions, file size, full path
|
|
115
|
+
|
|
116
|
+
### Auto-Refresh
|
|
117
|
+
|
|
118
|
+
- Meta refresh every 5 seconds: `<meta http-equiv="refresh" content="5">`
|
|
119
|
+
- Or JavaScript polling (less intrusive)
|
|
120
|
+
- Manual refresh button always available
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Freshness Tracking
|
|
125
|
+
|
|
126
|
+
### Storage
|
|
127
|
+
|
|
128
|
+
`~/.figure-viewer/history.json`:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"/path/to/project/outputs/figures": {
|
|
133
|
+
"figure_1.png": {
|
|
134
|
+
"mtime": 1709324400000,
|
|
135
|
+
"version": 1
|
|
136
|
+
},
|
|
137
|
+
"figure_1.png": {
|
|
138
|
+
"mtime": 1709324500000,
|
|
139
|
+
"version": 2
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Version Detection
|
|
146
|
+
|
|
147
|
+
If same filename appears with newer mtime:
|
|
148
|
+
- Increment version counter
|
|
149
|
+
- Track version history
|
|
150
|
+
- Display "v1", "v2", etc. on card
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## cmux Integration
|
|
155
|
+
|
|
156
|
+
### Opening the Viewer
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# Generate HTML
|
|
160
|
+
figure-viewer --output /tmp/figure-viewer.html
|
|
161
|
+
|
|
162
|
+
# Open in cmux browser (verify exact command)
|
|
163
|
+
cmux browser open file:///tmp/figure-viewer.html
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Watch + Open
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Watch and auto-open on first run
|
|
170
|
+
figure-viewer --watch --open
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
On watch detect new/modified figure:
|
|
174
|
+
1. Regenerate HTML
|
|
175
|
+
2. Refresh browser (cmux command TBD or manual)
|
|
176
|
+
|
|
177
|
+
### Fallback
|
|
178
|
+
|
|
179
|
+
If cmux not available or browser split fails:
|
|
180
|
+
- Open in default browser: `open file:///path/to/viewer.html`
|
|
181
|
+
- Print instruction to user
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## OpenCode Integration
|
|
186
|
+
|
|
187
|
+
### Custom Command
|
|
188
|
+
|
|
189
|
+
Create `.opencode/commands/figures.md`:
|
|
190
|
+
|
|
191
|
+
```yaml
|
|
192
|
+
---
|
|
193
|
+
description: Open figure viewer
|
|
194
|
+
agent: build
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
Run the figure viewer to check generated figures:
|
|
198
|
+
|
|
199
|
+
!`figure-viewer --open`
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Usage
|
|
203
|
+
|
|
204
|
+
In OpenCode:
|
|
205
|
+
```
|
|
206
|
+
/figures
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Or in conversation:
|
|
210
|
+
```
|
|
211
|
+
"Can you run /figures to check the plots we generated?"
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## File Structure
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
figure-viewer/
|
|
220
|
+
├── figure-viewer.js # Main CLI entry point
|
|
221
|
+
├── lib/
|
|
222
|
+
│ ├── discover.js # Find figures directory
|
|
223
|
+
│ ├── html.js # Generate HTML
|
|
224
|
+
│ ├── history.js # Track freshness/versions
|
|
225
|
+
│ └── watcher.js # File system watch (fswatch)
|
|
226
|
+
├── templates/
|
|
227
|
+
│ └── viewer.html # HTML template (inline in js or separate)
|
|
228
|
+
├── package.json
|
|
229
|
+
└── README.md
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Dependencies
|
|
235
|
+
|
|
236
|
+
- Node.js (or could be Go/Bash)
|
|
237
|
+
- `chokidar` or `fswatch` for file watching
|
|
238
|
+
- Optional: `open` CLI (macOS) for fallback browser open
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Edge Cases
|
|
243
|
+
|
|
244
|
+
1. **No figures directory found**
|
|
245
|
+
- Error message: "No figures directory found. Pass explicit path or create one of: outputs/figures/, figures/, plots/"
|
|
246
|
+
|
|
247
|
+
2. **Empty figures directory**
|
|
248
|
+
- Show message: "No figures yet. Run your analysis to generate figures."
|
|
249
|
+
|
|
250
|
+
3. **Very large images**
|
|
251
|
+
- Generate thumbnails for grid? Or lazy load
|
|
252
|
+
- Full size in lightbox
|
|
253
|
+
|
|
254
|
+
4. **Non-image files in folder**
|
|
255
|
+
- Filter to: .png, .jpg, .jpeg, .pdf, .svg
|
|
256
|
+
- Ignore everything else
|
|
257
|
+
|
|
258
|
+
5. **Path with spaces/special chars**
|
|
259
|
+
- Proper escaping in HTML file:// URLs
|
|
260
|
+
|
|
261
|
+
6. **Multiple projects**
|
|
262
|
+
- History stored per directory path
|
|
263
|
+
- Can clear history: `figure-viewer --clear-history`
|
|
264
|
+
|
|
265
|
+
7. **Watch mode permissions**
|
|
266
|
+
- Handle ENOENT if directory deleted while watching
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## Future Enhancements
|
|
271
|
+
|
|
272
|
+
- [ ] Version comparison: side-by-side v1 vs v2
|
|
273
|
+
- [ ] Annotations: add notes to figures
|
|
274
|
+
- [ ] Export: download all figures as zip
|
|
275
|
+
- [ ] Zoom: pinch-zoom in lightbox
|
|
276
|
+
- [ ] Slideshow: auto-advance mode
|
|
277
|
+
- [ ] AI captioning: generate descriptions with vision model
|
|
278
|
+
- [ ] Share: copy image URL or embed
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Acceptance Criteria
|
|
283
|
+
|
|
284
|
+
1. ✅ `figure-viewer` runs from CLI with no arguments and auto-discovers figures
|
|
285
|
+
2. ✅ Generated HTML displays grid of images with thumbnails
|
|
286
|
+
3. ✅ Freshness indicators show correct colors based on mtime
|
|
287
|
+
4. ✅ Click on image opens lightbox with full-size view
|
|
288
|
+
5. ✅ Lightbox supports keyboard navigation (ESC to close, arrows to navigate)
|
|
289
|
+
6. ✅ Watch mode detects new files and regenerates HTML
|
|
290
|
+
7. ✅ Opens in cmux browser split (or fallback to system browser)
|
|
291
|
+
8. ✅ Custom command `/figures` works in OpenCode
|
|
292
|
+
9. ✅ Works with nested directory structures (project/analysis/outputs/figures)
|
|
293
|
+
10. ✅ Handles empty directory gracefully with helpful message
|
package/figure-viewer.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const { discoverFiguresDirectory, getErrorMessage } = require('./lib/discover');
|
|
8
|
+
const { generateHtml, getImages } = require('./lib/html');
|
|
9
|
+
const { updateHistory, getHistoryForDir, clearHistory: clearHistoryFn } = require('./lib/history');
|
|
10
|
+
const { createWatcher, closeWatcher } = require('./lib/watcher');
|
|
11
|
+
const { getConfig } = require('./lib/config');
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
const PID_FILE = '.figure-viewer.pid';
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.name('figure-viewer')
|
|
19
|
+
.description('CLI tool for viewing and navigating scientific figures in a browser pane')
|
|
20
|
+
.version('1.0.0')
|
|
21
|
+
.option('-p, --path <path>', 'Explicit path to figures directory')
|
|
22
|
+
.option('-o, --output <path>', 'Output HTML file path', 'figure-viewer.html')
|
|
23
|
+
.option('-w, --watch', 'Watch for file changes and auto-refresh', false)
|
|
24
|
+
.option('--no-open', 'Do not open browser automatically')
|
|
25
|
+
.option('-r, --refresh <seconds>', 'Auto-refresh interval in seconds (default: 30)', parseInt)
|
|
26
|
+
.option('-t, --timeout <minutes>', 'Kill watcher after N minutes of inactivity (default: 60)', parseInt)
|
|
27
|
+
.option('--clear-history', 'Clear figure history')
|
|
28
|
+
.option('--kill', 'Kill the background watcher process');
|
|
29
|
+
|
|
30
|
+
program.parse(process.argv);
|
|
31
|
+
|
|
32
|
+
const options = program.opts();
|
|
33
|
+
|
|
34
|
+
function generateAndSave(figuresDir, outputPath) {
|
|
35
|
+
const config = getConfig();
|
|
36
|
+
// CLI options override config
|
|
37
|
+
if (options.refresh) {
|
|
38
|
+
config.refreshInterval = options.refresh * 1000;
|
|
39
|
+
}
|
|
40
|
+
if (options.timeout) {
|
|
41
|
+
config.watcherTimeout = options.timeout * 60 * 1000;
|
|
42
|
+
}
|
|
43
|
+
const images = getImages(figuresDir);
|
|
44
|
+
const history = updateHistory(figuresDir, images);
|
|
45
|
+
const html = generateHtml(figuresDir, { history, config });
|
|
46
|
+
|
|
47
|
+
fs.writeFileSync(outputPath, html);
|
|
48
|
+
console.log(`Generated: ${outputPath} (${images.length} figures)`);
|
|
49
|
+
|
|
50
|
+
return outputPath;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function openInBrowser(filePath) {
|
|
54
|
+
// Try cmux first, fallback to system browser
|
|
55
|
+
const fileUrl = 'file://' + filePath;
|
|
56
|
+
|
|
57
|
+
// Check if cmux socket exists (cmux sets CMUX_SOCKET_PATH or we can check default)
|
|
58
|
+
const cmuxSocket = process.env.CMUX_SOCKET_PATH || '/tmp/cmux.sock';
|
|
59
|
+
const hasCmux = fs.existsSync(cmuxSocket) || process.env.CMUX_WORKSPACE_ID;
|
|
60
|
+
|
|
61
|
+
if (hasCmux) {
|
|
62
|
+
console.log('Detected cmux session - opening in cmux browser...');
|
|
63
|
+
try {
|
|
64
|
+
const { execSync } = require('child_process');
|
|
65
|
+
// Try cmux browser open command
|
|
66
|
+
// Use new-pane with direction for cmux
|
|
67
|
+
execSync(`cmux new-pane --type browser --direction down --url "${fileUrl}"`, { stdio: 'inherit' });
|
|
68
|
+
console.log('Opened in cmux browser');
|
|
69
|
+
return;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.log('cmux command failed, falling back to system browser:', e.message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Fallback: use system open command
|
|
76
|
+
const { exec } = require('child_process');
|
|
77
|
+
exec(`open "${fileUrl}"`, (err) => {
|
|
78
|
+
if (err) {
|
|
79
|
+
console.error('Failed to open browser:', err.message);
|
|
80
|
+
} else {
|
|
81
|
+
console.log('Opened in default browser');
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function main() {
|
|
87
|
+
// Handle kill option
|
|
88
|
+
if (options.kill) {
|
|
89
|
+
const pidPath = path.join(process.cwd(), PID_FILE);
|
|
90
|
+
if (fs.existsSync(pidPath)) {
|
|
91
|
+
try {
|
|
92
|
+
const data = JSON.parse(fs.readFileSync(pidPath, 'utf8'));
|
|
93
|
+
const pid = data.pid;
|
|
94
|
+
process.kill(pid, 'SIGTERM');
|
|
95
|
+
console.log(`Killed watcher process (PID: ${pid})`);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.log('Watcher process already stopped or invalid PID file.');
|
|
98
|
+
}
|
|
99
|
+
// Clean up PID file
|
|
100
|
+
try { fs.unlinkSync(pidPath); } catch {}
|
|
101
|
+
} else {
|
|
102
|
+
console.log('No watcher process found in current directory.');
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Handle clear history
|
|
108
|
+
if (options.clearHistory) {
|
|
109
|
+
clearHistoryFn();
|
|
110
|
+
console.log('History cleared.');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Discover figures directory
|
|
115
|
+
let figuresDir;
|
|
116
|
+
try {
|
|
117
|
+
figuresDir = discoverFiguresDirectory(options.path);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error(err.message);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!figuresDir) {
|
|
124
|
+
console.error(getErrorMessage(FIGURE_DIRS));
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(`Figures directory: ${figuresDir}`);
|
|
129
|
+
|
|
130
|
+
// Generate HTML
|
|
131
|
+
const outputPath = path.resolve(options.output);
|
|
132
|
+
generateAndSave(figuresDir, outputPath);
|
|
133
|
+
|
|
134
|
+
// Check if watcher already running
|
|
135
|
+
const pidPath = path.join(process.cwd(), PID_FILE);
|
|
136
|
+
const watcherAlreadyRunning = fs.existsSync(pidPath);
|
|
137
|
+
|
|
138
|
+
// Watch mode - run as background process
|
|
139
|
+
if (options.watch) {
|
|
140
|
+
if (watcherAlreadyRunning) {
|
|
141
|
+
console.log('Watcher already running in this directory.');
|
|
142
|
+
} else {
|
|
143
|
+
const { spawn } = require('child_process');
|
|
144
|
+
|
|
145
|
+
// Get config for refresh and timeout
|
|
146
|
+
const config = getConfig();
|
|
147
|
+
const refreshMs = options.refresh ? options.refresh * 1000 : config.refreshInterval;
|
|
148
|
+
const timeoutMs = options.timeout ? options.timeout * 60 * 1000 : config.watcherTimeout;
|
|
149
|
+
|
|
150
|
+
// Pass the output directory as first arg (projectRoot)
|
|
151
|
+
const child = spawn(
|
|
152
|
+
process.execPath,
|
|
153
|
+
[
|
|
154
|
+
path.join(__dirname, 'lib/watcher-daemon.js'),
|
|
155
|
+
path.dirname(outputPath),
|
|
156
|
+
figuresDir,
|
|
157
|
+
outputPath,
|
|
158
|
+
refreshMs.toString(),
|
|
159
|
+
timeoutMs.toString()
|
|
160
|
+
],
|
|
161
|
+
{
|
|
162
|
+
cwd: __dirname,
|
|
163
|
+
detached: true,
|
|
164
|
+
stdio: 'ignore'
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
child.unref();
|
|
169
|
+
|
|
170
|
+
console.log('Watching for changes in background...');
|
|
171
|
+
console.log(`Auto-refresh every ${refreshMs/1000}s, timeout after ${Math.round(timeoutMs/60000)}min`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Open in browser (only if not already watching and not explicitly disabled)
|
|
176
|
+
if (options.open !== false && !watcherAlreadyRunning) {
|
|
177
|
+
openInBrowser(outputPath);
|
|
178
|
+
} else if (watcherAlreadyRunning) {
|
|
179
|
+
console.log('Browser already open. Use --no-open to skip browser entirely.');
|
|
180
|
+
} else if (!options.watch) {
|
|
181
|
+
// Only show URL for non-watch mode
|
|
182
|
+
console.log(`\nOpen this URL in your browser:\nfile://${outputPath}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
main().catch(console.error);
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
figuresDirs: [
|
|
6
|
+
'outputs/figures',
|
|
7
|
+
'figures',
|
|
8
|
+
'plots',
|
|
9
|
+
'output/figures',
|
|
10
|
+
'output/plots'
|
|
11
|
+
],
|
|
12
|
+
refreshInterval: 30000,
|
|
13
|
+
watcherTimeout: 3600000,
|
|
14
|
+
openOnStartup: true
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function getConfig(dir = process.cwd()) {
|
|
18
|
+
// Check for config in current directory first
|
|
19
|
+
const localConfig = path.join(dir, '.figure-viewer.json');
|
|
20
|
+
if (fs.existsSync(localConfig)) {
|
|
21
|
+
try {
|
|
22
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(localConfig, 'utf8')) };
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.warn('Invalid config file, using defaults');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check in home directory
|
|
29
|
+
const homeConfig = path.join(process.env.HOME || process.env.USERPROFILE, '.figure-viewer.json');
|
|
30
|
+
if (fs.existsSync(homeConfig)) {
|
|
31
|
+
try {
|
|
32
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(homeConfig, 'utf8')) };
|
|
33
|
+
} catch (e) {
|
|
34
|
+
// ignore
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return DEFAULT_CONFIG;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
getConfig,
|
|
43
|
+
DEFAULT_CONFIG
|
|
44
|
+
};
|
package/lib/discover.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { getConfig, DEFAULT_CONFIG } = require('./config');
|
|
4
|
+
|
|
5
|
+
function discoverFiguresDirectory(startPath, config = null) {
|
|
6
|
+
// If explicit path provided, validate it
|
|
7
|
+
if (startPath) {
|
|
8
|
+
const resolvedPath = path.resolve(startPath);
|
|
9
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
10
|
+
throw new Error(`Directory not found: ${resolvedPath}`);
|
|
11
|
+
}
|
|
12
|
+
const stats = fs.statSync(resolvedPath);
|
|
13
|
+
if (!stats.isDirectory()) {
|
|
14
|
+
throw new Error(`Not a directory: ${resolvedPath}`);
|
|
15
|
+
}
|
|
16
|
+
return resolvedPath;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const cfg = config || getConfig();
|
|
20
|
+
const figureDirs = cfg.figuresDirs || DEFAULT_CONFIG.figuresDirs;
|
|
21
|
+
|
|
22
|
+
// Start at current working directory
|
|
23
|
+
let currentDir = process.cwd();
|
|
24
|
+
const root = path.parse(currentDir).root;
|
|
25
|
+
|
|
26
|
+
while (currentDir !== root) {
|
|
27
|
+
for (const figureDir of figureDirs) {
|
|
28
|
+
const candidatePath = path.join(currentDir, figureDir);
|
|
29
|
+
if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isDirectory()) {
|
|
30
|
+
return candidatePath;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
currentDir = path.dirname(currentDir);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check root level as well
|
|
37
|
+
for (const figureDir of figureDirs) {
|
|
38
|
+
const candidatePath = path.join(root, figureDir);
|
|
39
|
+
if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isDirectory()) {
|
|
40
|
+
return candidatePath;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getErrorMessage(suggestedDirs) {
|
|
48
|
+
return `No figures directory found. Pass explicit path or create one of: ${suggestedDirs.join(', ')}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
discoverFiguresDirectory,
|
|
53
|
+
getErrorMessage
|
|
54
|
+
};
|