@henryavila/mdprobe 0.1.0 → 0.2.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/README.md +173 -185
- package/README.pt-BR.md +392 -0
- package/bin/cli.js +78 -60
- package/dist/assets/index-Cp_TccJA.css +1 -0
- package/dist/assets/index-DuVgC81Y.js +2 -0
- package/dist/index.html +3 -3
- package/package.json +5 -2
- package/schema.json +2 -2
- package/skills/mdprobe/SKILL.md +143 -278
- package/src/annotations.js +1 -1
- package/src/export.js +1 -1
- package/src/mcp.js +258 -0
- package/src/open-browser.js +27 -0
- package/src/server.js +93 -10
- package/src/setup-ui.js +108 -0
- package/src/setup.js +203 -0
- package/src/singleton.js +236 -0
- package/src/ui/app.jsx +21 -12
- package/src/ui/components/RightPanel.jsx +128 -69
- package/src/ui/hooks/useAnnotations.js +6 -1
- package/src/ui/hooks/useClientLibs.js +2 -2
- package/src/ui/hooks/useWebSocket.js +7 -3
- package/src/ui/index.html +1 -1
- package/src/ui/state/store.js +9 -0
- package/src/ui/styles/themes.css +43 -3
- package/dist/assets/index-DPysqH1p.js +0 -2
- package/dist/assets/index-nl9v2RuJ.css +0 -1
package/src/setup.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rm, access } from 'node:fs/promises'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
8
|
+
const PROJECT_ROOT = join(dirname(__filename), '..')
|
|
9
|
+
const SKILL_SOURCE = join(PROJECT_ROOT, 'skills', 'mdprobe', 'SKILL.md')
|
|
10
|
+
const DEFAULT_CONFIG_PATH = join(homedir(), '.mdprobe.json')
|
|
11
|
+
|
|
12
|
+
// IDE skill directory mappings
|
|
13
|
+
const IDE_CONFIGS = {
|
|
14
|
+
'Claude Code': { skillsDir: join(homedir(), '.claude', 'skills') },
|
|
15
|
+
'Cursor': { skillsDir: join(homedir(), '.cursor', 'skills') },
|
|
16
|
+
'Gemini': { skillsDir: join(homedir(), '.gemini', 'skills') },
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect which IDEs have skill directories.
|
|
21
|
+
* @returns {Promise<string[]>} List of IDE names with skills dirs
|
|
22
|
+
*/
|
|
23
|
+
export async function detectIDEs() {
|
|
24
|
+
const detected = []
|
|
25
|
+
for (const [name, config] of Object.entries(IDE_CONFIGS)) {
|
|
26
|
+
try {
|
|
27
|
+
await access(config.skillsDir)
|
|
28
|
+
detected.push(name)
|
|
29
|
+
} catch {
|
|
30
|
+
// Directory doesn't exist
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return detected
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Install the SKILL.md file to an IDE's skill directory.
|
|
38
|
+
* @param {string} ide - IDE name (e.g., 'Claude Code')
|
|
39
|
+
* @param {string} [content] - Skill content (reads from source if omitted)
|
|
40
|
+
* @returns {Promise<string>} Path where skill was installed
|
|
41
|
+
*/
|
|
42
|
+
export async function installSkill(ide, content) {
|
|
43
|
+
const config = IDE_CONFIGS[ide]
|
|
44
|
+
if (!config) throw new Error(`Unknown IDE: ${ide}`)
|
|
45
|
+
|
|
46
|
+
if (!content) {
|
|
47
|
+
content = await readFile(SKILL_SOURCE, 'utf-8')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const destDir = join(config.skillsDir, 'mdprobe')
|
|
51
|
+
await mkdir(destDir, { recursive: true })
|
|
52
|
+
const destPath = join(destDir, 'SKILL.md')
|
|
53
|
+
await writeFile(destPath, content, 'utf-8')
|
|
54
|
+
return destPath
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register the MCP server via claude CLI.
|
|
59
|
+
* Falls back to direct ~/.claude.json write if CLI not available.
|
|
60
|
+
* @returns {Promise<{method: string}>}
|
|
61
|
+
*/
|
|
62
|
+
export async function registerMCP() {
|
|
63
|
+
try {
|
|
64
|
+
await execFileAsync('claude', [
|
|
65
|
+
'mcp', 'add', '--scope', 'user', '--transport', 'stdio',
|
|
66
|
+
'mdprobe', '--', 'mdprobe', 'mcp',
|
|
67
|
+
])
|
|
68
|
+
return { method: 'cli' }
|
|
69
|
+
} catch {
|
|
70
|
+
// Fallback: write directly to ~/.claude.json
|
|
71
|
+
const claudeJsonPath = join(homedir(), '.claude.json')
|
|
72
|
+
let config = {}
|
|
73
|
+
try {
|
|
74
|
+
config = JSON.parse(await readFile(claudeJsonPath, 'utf-8'))
|
|
75
|
+
} catch { /* start fresh */ }
|
|
76
|
+
|
|
77
|
+
if (!config.mcpServers) config.mcpServers = {}
|
|
78
|
+
config.mcpServers.mdprobe = {
|
|
79
|
+
command: 'mdprobe',
|
|
80
|
+
args: ['mcp'],
|
|
81
|
+
type: 'stdio',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await writeFile(claudeJsonPath, JSON.stringify(config, null, 2), 'utf-8')
|
|
85
|
+
return { method: 'file' }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register PostToolUse hook in settings.json (safe merge).
|
|
91
|
+
* @param {string} [settingsPath] - Override path for testing
|
|
92
|
+
* @returns {Promise<{added: boolean}>}
|
|
93
|
+
*/
|
|
94
|
+
export async function registerHook(settingsPath) {
|
|
95
|
+
if (!settingsPath) {
|
|
96
|
+
settingsPath = join(homedir(), '.claude', 'settings.json')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let settings = {}
|
|
100
|
+
try {
|
|
101
|
+
settings = JSON.parse(await readFile(settingsPath, 'utf-8'))
|
|
102
|
+
} catch { /* start fresh */ }
|
|
103
|
+
|
|
104
|
+
if (!settings.hooks) settings.hooks = {}
|
|
105
|
+
if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = []
|
|
106
|
+
|
|
107
|
+
// Check if mdprobe hook already exists
|
|
108
|
+
const existing = settings.hooks.PostToolUse.find(h =>
|
|
109
|
+
h.hooks?.some(hh => typeof hh.command === 'string' && hh.command.includes('[mdprobe]'))
|
|
110
|
+
)
|
|
111
|
+
if (existing) return { added: false }
|
|
112
|
+
|
|
113
|
+
const hookCommand = `node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const p=d.tool_input?.file_path||''; if(p.endsWith('.md')){const j={decision:'allow',reason:'[mdprobe] .md file modified: '+require('path').basename(p)+'. Offer to open with mdProbe.'}; process.stdout.write(JSON.stringify(j))}"`
|
|
114
|
+
|
|
115
|
+
settings.hooks.PostToolUse.push({
|
|
116
|
+
matcher: 'Write|Edit',
|
|
117
|
+
hooks: [{ type: 'command', command: hookCommand }],
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await mkdir(dirname(settingsPath), { recursive: true })
|
|
121
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8')
|
|
122
|
+
return { added: true }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Save user config to ~/.mdprobe.json.
|
|
127
|
+
* @param {object} config - { author, urlStyle }
|
|
128
|
+
* @param {string} [configPath]
|
|
129
|
+
*/
|
|
130
|
+
export async function saveConfig(config, configPath = DEFAULT_CONFIG_PATH) {
|
|
131
|
+
await mkdir(dirname(configPath), { recursive: true })
|
|
132
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Remove all mdprobe installations.
|
|
137
|
+
* @param {object} [opts]
|
|
138
|
+
* @param {string} [opts.configPath]
|
|
139
|
+
* @param {string} [opts.settingsPath]
|
|
140
|
+
*/
|
|
141
|
+
export async function removeAll(opts = {}) {
|
|
142
|
+
const configPath = opts.configPath || DEFAULT_CONFIG_PATH
|
|
143
|
+
const settingsPath = opts.settingsPath || join(homedir(), '.claude', 'settings.json')
|
|
144
|
+
const removed = []
|
|
145
|
+
|
|
146
|
+
// Remove skill from all IDEs
|
|
147
|
+
for (const [name, config] of Object.entries(IDE_CONFIGS)) {
|
|
148
|
+
const skillDir = join(config.skillsDir, 'mdprobe')
|
|
149
|
+
try {
|
|
150
|
+
await rm(skillDir, { recursive: true })
|
|
151
|
+
removed.push(`skill:${name}`)
|
|
152
|
+
} catch { /* not installed */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Remove MCP registration (both CLI and file-based)
|
|
156
|
+
try {
|
|
157
|
+
await execFileAsync('claude', ['mcp', 'remove', 'mdprobe'])
|
|
158
|
+
removed.push('mcp:cli')
|
|
159
|
+
} catch { /* CLI not available or entry not found */ }
|
|
160
|
+
|
|
161
|
+
// Always clean up file-based entry too (may exist from fallback registration)
|
|
162
|
+
try {
|
|
163
|
+
const claudeJsonPath = join(homedir(), '.claude.json')
|
|
164
|
+
const config = JSON.parse(await readFile(claudeJsonPath, 'utf-8'))
|
|
165
|
+
if (config.mcpServers?.mdprobe) {
|
|
166
|
+
delete config.mcpServers.mdprobe
|
|
167
|
+
await writeFile(claudeJsonPath, JSON.stringify(config, null, 2), 'utf-8')
|
|
168
|
+
removed.push('mcp:file')
|
|
169
|
+
}
|
|
170
|
+
} catch { /* ignore */ }
|
|
171
|
+
|
|
172
|
+
// Remove hook from settings.json
|
|
173
|
+
try {
|
|
174
|
+
const settings = JSON.parse(await readFile(settingsPath, 'utf-8'))
|
|
175
|
+
if (settings.hooks?.PostToolUse) {
|
|
176
|
+
const before = settings.hooks.PostToolUse.length
|
|
177
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(h =>
|
|
178
|
+
!h.hooks?.some(hh => typeof hh.command === 'string' && hh.command.includes('[mdprobe]'))
|
|
179
|
+
)
|
|
180
|
+
if (settings.hooks.PostToolUse.length < before) {
|
|
181
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8')
|
|
182
|
+
removed.push('hook')
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch { /* ignore */ }
|
|
186
|
+
|
|
187
|
+
// Remove config file
|
|
188
|
+
try {
|
|
189
|
+
await rm(configPath)
|
|
190
|
+
removed.push('config')
|
|
191
|
+
} catch { /* ignore */ }
|
|
192
|
+
|
|
193
|
+
return removed
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function execFileAsync(cmd, args) {
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
execFile(cmd, args, (err, stdout) => {
|
|
199
|
+
if (err) reject(err)
|
|
200
|
+
else resolve(stdout)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
}
|
package/src/singleton.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { readFile, writeFile, unlink } from 'node:fs/promises'
|
|
2
|
+
import { unlinkSync } from 'node:fs'
|
|
3
|
+
import { join, dirname } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import node_http from 'node:http'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { hashContent } from './hash.js'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const DIST_INDEX = join(dirname(__filename), '..', 'dist', 'index.html')
|
|
11
|
+
|
|
12
|
+
const DEFAULT_LOCK_PATH = join(tmpdir(), 'mdprobe.lock')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compute a build hash from dist/index.html content.
|
|
16
|
+
* Falls back to package version if dist doesn't exist (dev mode).
|
|
17
|
+
* @returns {Promise<string>}
|
|
18
|
+
*/
|
|
19
|
+
export async function computeBuildHash() {
|
|
20
|
+
try {
|
|
21
|
+
const content = await readFile(DIST_INDEX, 'utf-8')
|
|
22
|
+
return hashContent(content)
|
|
23
|
+
} catch {
|
|
24
|
+
try {
|
|
25
|
+
const pkg = JSON.parse(await readFile(join(dirname(__filename), '..', 'package.json'), 'utf-8'))
|
|
26
|
+
return `pkg:${pkg.version}`
|
|
27
|
+
} catch {
|
|
28
|
+
return 'unknown'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read and parse the lock file.
|
|
35
|
+
* @param {string} [lockPath]
|
|
36
|
+
* @returns {Promise<{pid: number, port: number, url: string, startedAt: string} | null>}
|
|
37
|
+
*/
|
|
38
|
+
export async function readLockFile(lockPath = DEFAULT_LOCK_PATH) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await readFile(lockPath, 'utf-8')
|
|
41
|
+
return JSON.parse(raw)
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write lock file with server instance data.
|
|
49
|
+
* @param {{pid: number, port: number, url: string, startedAt: string}} data
|
|
50
|
+
* @param {string} [lockPath]
|
|
51
|
+
*/
|
|
52
|
+
export async function writeLockFile(data, lockPath = DEFAULT_LOCK_PATH) {
|
|
53
|
+
await writeFile(lockPath, JSON.stringify(data), 'utf-8')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Remove lock file. Silently ignores ENOENT.
|
|
58
|
+
* @param {string} [lockPath]
|
|
59
|
+
*/
|
|
60
|
+
export async function removeLockFile(lockPath = DEFAULT_LOCK_PATH) {
|
|
61
|
+
try {
|
|
62
|
+
await unlink(lockPath)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (err.code !== 'ENOENT') throw err
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Synchronous lock file removal — last resort for process.on('exit').
|
|
70
|
+
* @param {string} [lockPath]
|
|
71
|
+
*/
|
|
72
|
+
export function removeLockFileSync(lockPath = DEFAULT_LOCK_PATH) {
|
|
73
|
+
try {
|
|
74
|
+
unlinkSync(lockPath)
|
|
75
|
+
} catch { /* ignore */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if a process with the given PID is alive.
|
|
80
|
+
* @param {number} pid
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
export function isProcessAlive(pid) {
|
|
84
|
+
try {
|
|
85
|
+
process.kill(pid, 0)
|
|
86
|
+
return true
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err.code === 'EPERM') return true
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Ping a server's /api/status endpoint to verify it's a running mdprobe instance.
|
|
95
|
+
* @param {string} url - Base URL (e.g. "http://127.0.0.1:3000")
|
|
96
|
+
* @param {number} [timeout=2000]
|
|
97
|
+
* @returns {Promise<{alive: boolean}>}
|
|
98
|
+
*/
|
|
99
|
+
export function pingServer(url, timeout = 2000) {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
const req = node_http.get(`${url}/api/status`, { timeout }, (res) => {
|
|
102
|
+
let data = ''
|
|
103
|
+
res.on('data', (chunk) => { data += chunk })
|
|
104
|
+
res.on('end', () => {
|
|
105
|
+
try {
|
|
106
|
+
const json = JSON.parse(data)
|
|
107
|
+
resolve({ alive: json.identity === 'mdprobe' })
|
|
108
|
+
} catch {
|
|
109
|
+
resolve({ alive: false })
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
req.on('error', () => resolve({ alive: false }))
|
|
114
|
+
req.on('timeout', () => {
|
|
115
|
+
req.destroy()
|
|
116
|
+
resolve({ alive: false })
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Discover an existing running mdprobe server via lock file + HTTP verification.
|
|
123
|
+
* Cleans up stale lock files automatically.
|
|
124
|
+
* @param {string} [lockPath]
|
|
125
|
+
* @param {string} [currentBuildHash] - If provided, reject servers with different buildHash
|
|
126
|
+
* @returns {Promise<{url: string, port: number} | null>}
|
|
127
|
+
*/
|
|
128
|
+
export async function discoverExistingServer(lockPath = DEFAULT_LOCK_PATH, currentBuildHash) {
|
|
129
|
+
const lock = await readLockFile(lockPath)
|
|
130
|
+
if (!lock) return null
|
|
131
|
+
|
|
132
|
+
// Reject lock files without buildHash when we have one (backward compat)
|
|
133
|
+
if (currentBuildHash && !lock.buildHash) {
|
|
134
|
+
await removeLockFile(lockPath)
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Reject lock files with different buildHash (stale server)
|
|
139
|
+
if (currentBuildHash && lock.buildHash !== currentBuildHash) {
|
|
140
|
+
await removeLockFile(lockPath)
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!isProcessAlive(lock.pid)) {
|
|
145
|
+
await removeLockFile(lockPath)
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { alive } = await pingServer(lock.url)
|
|
150
|
+
if (!alive) {
|
|
151
|
+
await removeLockFile(lockPath)
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { url: lock.url, port: lock.port }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Join an existing server by adding files via its HTTP API.
|
|
160
|
+
* @param {string} url - Base URL of the running server
|
|
161
|
+
* @param {string[]} files - Absolute file paths to add
|
|
162
|
+
* @returns {Promise<{ok: boolean, files?: string[], added?: string[]}>}
|
|
163
|
+
*/
|
|
164
|
+
export function joinExistingServer(url, files) {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const body = JSON.stringify({ files })
|
|
167
|
+
const parsed = new URL(`${url}/api/add-files`)
|
|
168
|
+
|
|
169
|
+
const req = node_http.request({
|
|
170
|
+
hostname: parsed.hostname,
|
|
171
|
+
port: parsed.port,
|
|
172
|
+
path: parsed.pathname,
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
'Content-Length': Buffer.byteLength(body),
|
|
177
|
+
},
|
|
178
|
+
timeout: 5000,
|
|
179
|
+
}, (res) => {
|
|
180
|
+
let data = ''
|
|
181
|
+
res.on('data', (chunk) => { data += chunk })
|
|
182
|
+
res.on('end', () => {
|
|
183
|
+
try {
|
|
184
|
+
const json = JSON.parse(data)
|
|
185
|
+
resolve({ ok: json.ok === true, files: json.files, added: json.added })
|
|
186
|
+
} catch {
|
|
187
|
+
resolve({ ok: false })
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
req.on('error', () => resolve({ ok: false }))
|
|
193
|
+
req.on('timeout', () => {
|
|
194
|
+
req.destroy()
|
|
195
|
+
resolve({ ok: false })
|
|
196
|
+
})
|
|
197
|
+
req.end(body)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let shuttingDown = false
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Register signal handlers to clean up lock file and close server on exit.
|
|
205
|
+
* @param {{close: Function}} serverObj
|
|
206
|
+
* @param {string} [lockPath]
|
|
207
|
+
*/
|
|
208
|
+
export function registerShutdownHandlers(serverObj, lockPath = DEFAULT_LOCK_PATH) {
|
|
209
|
+
shuttingDown = false
|
|
210
|
+
|
|
211
|
+
async function shutdown() {
|
|
212
|
+
if (shuttingDown) return
|
|
213
|
+
shuttingDown = true
|
|
214
|
+
|
|
215
|
+
await removeLockFile(lockPath)
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await serverObj.close()
|
|
219
|
+
} catch { /* ignore */ }
|
|
220
|
+
|
|
221
|
+
process.exit(0)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
process.on('SIGINT', shutdown)
|
|
225
|
+
process.on('SIGTERM', shutdown)
|
|
226
|
+
|
|
227
|
+
// Synchronous last-resort cleanup on exit
|
|
228
|
+
process.on('exit', () => {
|
|
229
|
+
removeLockFileSync(lockPath)
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export { DEFAULT_LOCK_PATH }
|
|
234
|
+
|
|
235
|
+
// For testing
|
|
236
|
+
export function _resetShutdownFlag() { shuttingDown = false }
|
package/src/ui/app.jsx
CHANGED
|
@@ -6,7 +6,8 @@ import { useTheme } from './hooks/useTheme.js'
|
|
|
6
6
|
import { useAnnotations } from './hooks/useAnnotations.js'
|
|
7
7
|
import { useClientLibs } from './hooks/useClientLibs.js'
|
|
8
8
|
import { files, currentFile, currentHtml, currentToc, author, reviewMode,
|
|
9
|
-
leftPanelOpen, rightPanelOpen, openAnnotations, sectionStats, driftWarning
|
|
9
|
+
leftPanelOpen, rightPanelOpen, openAnnotations, sectionStats, driftWarning,
|
|
10
|
+
orphanedAnnotations } from './state/store.js'
|
|
10
11
|
import { LeftPanel } from './components/LeftPanel.jsx'
|
|
11
12
|
import { RightPanel } from './components/RightPanel.jsx'
|
|
12
13
|
import { Content } from './components/Content.jsx'
|
|
@@ -40,17 +41,23 @@ function App() {
|
|
|
40
41
|
useEffect(() => {
|
|
41
42
|
fetch('/api/files').then(r => r.json()).then(data => {
|
|
42
43
|
files.value = data
|
|
43
|
-
if (data.length
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
if (data.length === 0) return
|
|
45
|
+
|
|
46
|
+
// Deep link: check if URL pathname matches a file
|
|
47
|
+
const pathname = window.location.pathname
|
|
48
|
+
let target = null
|
|
49
|
+
if (pathname && pathname !== '/') {
|
|
50
|
+
const cleaned = pathname.replace(/^\//, '')
|
|
51
|
+
target = data.find(f => {
|
|
52
|
+
const fp = f.path || f
|
|
53
|
+
return fp === cleaned ||
|
|
54
|
+
fp === cleaned.split('/').pop() ||
|
|
55
|
+
(f.absPath && f.absPath.endsWith('/' + cleaned))
|
|
50
56
|
})
|
|
51
|
-
// Fetch annotations
|
|
52
|
-
annotationOps.fetchAnnotations(first)
|
|
53
57
|
}
|
|
58
|
+
|
|
59
|
+
const selected = target ? (target.path || target) : (data[0].path || data[0])
|
|
60
|
+
handleFileSelect(selected)
|
|
54
61
|
})
|
|
55
62
|
|
|
56
63
|
fetch('/api/config').then(r => r.json()).then(data => {
|
|
@@ -76,7 +83,7 @@ function App() {
|
|
|
76
83
|
<>
|
|
77
84
|
{/* Header */}
|
|
78
85
|
<header class="header">
|
|
79
|
-
<h1>
|
|
86
|
+
<h1>mdProbe</h1>
|
|
80
87
|
<span class="header-file">{currentFile.value || 'No file selected'}</span>
|
|
81
88
|
<div style="flex: 1" />
|
|
82
89
|
{sectionStats.value.total > 0 && (reviewMode.value || sectionStats.value.reviewed > 0) && (
|
|
@@ -99,7 +106,9 @@ function App() {
|
|
|
99
106
|
{/* Drift warning banner */}
|
|
100
107
|
{driftWarning.value && (
|
|
101
108
|
<div class="drift-banner">
|
|
102
|
-
|
|
109
|
+
{orphanedAnnotations.value.length > 0
|
|
110
|
+
? `Arquivo modificado — ${orphanedAnnotations.value.length} anotação(ões) não encontrada(s)`
|
|
111
|
+
: 'Arquivo modificado desde a ultima revisao. Algumas anotacoes podem estar desalinhadas.'}
|
|
103
112
|
<button class="btn btn-sm" style="margin-left: 8px" onClick={() => driftWarning.value = false}>Dismiss</button>
|
|
104
113
|
</div>
|
|
105
114
|
)}
|