SLOPSHOPPER

drawio-viewer

Anteprima dei diagrammi draw.io nel pannello, aggiornata quando Claude o tu li modificate

newpaneguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · drawio-viewer
│ ┃ draw.io ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ draw.io │ ┃ Nessun diagramma aperto. Usa /drawio ⏺ Read(src/auth.ts) │ ┃ percorso/file.drawio: se il file non esiste ⎿ Read 6 lines │ ┃ lo crea vuoto. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /drawio │ ⎿ drawio-viewer: Uso: /drawio percorso/file.drawio (se il file non │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · draw.io
◆ draw.io Nessun diagramma aperto. Usa /drawio percorso/file.drawio: se il file non esiste lo crea vuoto.
README

drawio-viewer

Un pannello che mostra i diagrammi draw.io (.drawio) e li ridisegna quando Claude o tu li modificate. Così vedi il diagramma senza uscire da Claude Code.

Come si usa

/drawio percorso/file.drawio apre il diagramma nel pannello. Se il file non esiste lo crea vuoto e risponde «Creato …, un diagramma vuoto: chiedi a Claude di disegnarci qualcosa.». Senza argomento apre il pannello; se è vuoto ricorda l'uso.

TastoCosa fa
1–9passa al diagramma di quella scheda (solo i primi nove)
p / npagina precedente / successiva (solo con più pagine)
g«Anteprima grande»: apre il PNG in Anteprima di macOS (quando il PNG c'è)
v«Modifica in VS Code»
ipassa da «Vista a caratteri» a «Vista immagine» e ritorno (solo dove le immagini si vedono)
d«Apri in draw.io»
r«Aggiorna»: esporta di nuovo
x«Chiudi»: toglie il diagramma dal pannello, non cancella il file

Il pannello mostra le schede, il percorso, la pagina e poi il diagramma. Nel terminale è un'immagine PNG esportata da draw.io. Altrove, per esempio nell'app desktop, è un disegno a caratteri ricavato dall'XML. Mentre esporta scrive «Disegno l’anteprima…». Se l'esportazione fallisce mostra l'errore di draw.io e un pulsante «Riprova».

Cosa fa da sola

  • Dopo ogni Edit o Write riuscito su un file .drawio lo mette nel pannello e lo riesporta. L'esportazione parte dopo la risposta all'agente, che non aspetta il disegno.
  • Alla prima modifica apre il pannello una volta sola. Se il terminale è stretto mostra «draw.io: allarga il terminale (144 colonne) o usa /drawio».
  • Ogni 2 secondi, solo con il pannello in vista, controlla la data di modifica dei diagrammi aperti. Se un file è stato salvato da fuori (VS Code, draw.io) lo riesporta.
  • Un diagramma salvato compresso viene segnalato: Claude non riesce a modificarlo bene. Il pannello suggerisce di togliere «Compresso» da File › Proprietà in draw.io.

Cosa legge e cosa manda

  • File letti: i .drawio aperti nel pannello (contenuto e data di modifica).
  • File scritti: un diagramma vuoto, solo se /drawio riceve un file che non esiste; i PNG in ~/Library/Caches/drawio-viewer/, uno per file e pagina, che la mod non cancella.
  • Processi:
  • mkdir -p ~/Library/Caches/drawio-viewer
  • /Applications/draw.io.app/Contents/MacOS/draw.io --export --format png --scale 2 --border 16 --page-index <pagina> --output <png> <file> (timeout 60 secondi)
  • sips -g pixelWidth -g pixelHeight <png> per la misura dell'immagine
  • dai pulsanti: code <file>, open -a draw.io <file>, open -a Preview <png>
  • Modello: nessuna chiamata.
  • Variabili d'ambiente: HOME, CLAUDE_CODE_SESSION_KIND, CLAUDE_CODE_FORCE_TERMINAL_IMAGES.
  • Stato della sessione: per ogni diagramma percorso, pagina, numero di pagine, se è compresso, data di modifica, esito, errore, percorso e misura del PNG, forme e collegamenti letti dall'XML; il diagramma scelto; se il pannello si è già aperto da solo; se è attiva la vista a caratteri.

Requisiti

  • macOS, per il PNG (draw.io in /Applications, sips, open -a, ~/Library/Caches); altrove il pannello dice che draw.io non c'è e mostra solo il disegno a caratteri.
  • draw.io Desktop in /Applications/draw.io.app (brew install --cask drawio) per il PNG. Senza, il pannello lo dice e mostra il disegno a caratteri.
  • VS Code con il comando code nel PATH, per «Modifica in VS Code».
  • Immagini nelle sessioni in background: Claude Code non disegna immagini in una sessione in background (CLAUDE_CODE_SESSION_KIND uguale a bg). Lì il pannello mostra il disegno a caratteri e questo avviso: «Qui Claude Code spegne le immagini: per vedere il PNG aggiungi "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" in "env" di ~/.claude/settings.json e riavvia Claude.». Va bene qualunque valore diverso da vuoto, 0 e false.

Limiti

  • Un diagramma compresso non ha la vista a caratteri: solo il PNG.
  • Il disegno a caratteri arriva al massimo a 150 righe e disegna ogni forma come rettangolo, ellisse, rombo o testo.
  • L'immagine non supera 255 celle per lato; anche in un pannello basso ha almeno 8 righe di spazio.
  • Il pannello si apre da solo da 144 colonne in su (da 110 se l'hai già aperto tu e non l'hai chiuso a mano); con /drawio si apre a ogni larghezza.
  • Il primo export può essere lento: draw.io avvia Electron a ogni esportazione.

Installazione

claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install drawio-viewer@enrik-mods

Oppure, dal prompt di Claude Code: /plugin install drawio-viewer --marketplace Wowbagger1994/claude-mods.

Sviluppo

Dalla cartella della mod:

claude plugin validate .
claude plugin test .

I test sono 38, in 3 file.

Source 6 files
hooks/register.tsx 242 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Diagram } from '../types'
5import {
6  DRAWIO_BIN,
7  EMPTY_DIAGRAM,
8  exportArgv,
9  isCompressed,
10  isDrawioPath,
11  lastLine,
12  pageCount,
13  parseSips,
14  previewName,
15  upsert,
16} from './diagram'
17import { pagesOf, parseModel } from './drawioxml'
18import type { Actions } from './view'
19import { emptyView, paneView } from './view'
20
21const PLUGIN = 'drawio-viewer'
22const PANE = 'drawio-viewer'
23const TITLE = 'draw.io'
24/** How often an open pane looks for a save from VS Code or draw.io. */
25const POLL_MS = 2_000
26/** draw.io starts Electron for each export: the first can take a while. */
27const EXPORT_TIMEOUT_MS = 60_000
28const USAGE = 'Uso: /drawio percorso/file.drawio (se il file non esiste lo crea vuoto).'
29
30const diagrams = atom({ plugin: 'drawio-viewer', key: 'diagrams' } as const, [])
31const selected = atom({ plugin: 'drawio-viewer', key: 'selected' } as const, null)
32const hasAutoOpened = atom({ plugin: 'drawio-viewer', key: 'hasAutoOpened' } as const, false)
33const isTextView = atom({ plugin: 'drawio-viewer', key: 'isTextView' } as const, false)
34
35/** Paths being exported now, so a poll and an edit never export one file twice at once. */
36const exporting = new Set<string>()
37/** Paths whose file changed while they were exporting: exported again once that ends. */
38const again = new Set<string>()
39
40const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE })
41
42const autoOpenOnce = async ($: EngineInterface) => {
43  if (await read($, hasAutoOpened)) return
44  await update($, hasAutoOpened, () => true)
45  const opened = await openPane($)
46  if (!opened.isPlaced) $.ui.toast('draw.io: allarga il terminale (144 colonne) o usa /drawio')
47}
48
49const setDiagram = ($: EngineInterface, path: string, fn: (d: Diagram) => Diagram) =>
50  update($, diagrams, list => upsert(list, path, fn))
51
52const cacheDir = async ($: EngineInterface): Promise<string> => {
53  const home = (await $.env.get('HOME')) ?? '/tmp'
54  const dir = `${home}/Library/Caches/drawio-viewer`
55  await $.process.run(['mkdir', '-p', dir])
56  return dir
57}
58
59/** Exports the diagram's page to PNG and records it; any failure is shown in the pane, never thrown. */
60const exportOnce = async ($: EngineInterface, path: string) => {
61  const { mtimeMs } = await $.fs.stat(path)
62  const xml = await $.fs.read(path)
63  const pages = pageCount(xml)
64  const current = (await read($, diagrams)).find(d => d.path === path)
65  const page = Math.min(current?.page ?? 1, pages)
66  const compressed = isCompressed(xml)
67  // The text view needs only the XML: it is ready before (and without) draw.io.
68  const model = compressed ? null : parseModel(pagesOf(xml)[page - 1] ?? '')
69  await setDiagram($, path, d => ({ ...d, page, pageCount: pages, isCompressed: compressed, mtimeMs, model, status: 'exporting' }))
70
71  if (!(await $.fs.exists(DRAWIO_BIN))) {
72    await setDiagram($, path, d => ({ ...d, status: 'no-drawio', error: null }))
73    return
74  }
75
76  const out = `${await cacheDir($)}/${previewName(path, page)}`
77  const ran = await $.process.run(exportArgv(path, page, out), { timeoutMs: EXPORT_TIMEOUT_MS })
78  if (ran.exitCode !== 0) {
79    await setDiagram($, path, d => ({ ...d, status: 'failed', error: lastLine(ran.stderr || ran.stdout) }))
80    return
81  }
82  const size = parseSips((await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', out])).stdout)
83  if (size === null) {
84    await setDiagram($, path, d => ({ ...d, status: 'failed', error: 'draw.io non ha scritto un PNG leggibile' }))
85    return
86  }
87  const generation = await $.clock.now()
88  await setDiagram($, path, d => ({ ...d, status: 'ready', error: null, preview: { png: out, ...size, generation } }))
89}
90
91/**
92 * Claude Code draws no pictures in a background session (`claude --bg`, or
93 * one it moved behind its pty host) unless CLAUDE_CODE_FORCE_TERMINAL_IMAGES
94 * is set: Ghostty would show them, the engine just does not try.
95 */
96const areImagesBlocked = async ($: EngineInterface): Promise<boolean> => {
97  const forced = (await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) ?? ''
98  const isForced = forced !== '' && forced !== '0' && forced.toLowerCase() !== 'false'
99  return (await $.env.get('CLAUDE_CODE_SESSION_KIND')) === 'bg' && !isForced
100}
101
102const refresh = async ($: EngineInterface, path: string) => {
103  if (exporting.has(path)) {
104    again.add(path)
105    return
106  }
107  exporting.add(path)
108  try {
109    do {
110      again.delete(path)
111      await exportOnce($, path)
112    } while (again.has(path))
113  } catch (error) {
114    await setDiagram($, path, d => ({ ...d, status: 'failed', error: String(error) }))
115  } finally {
116    exporting.delete(path)
117  }
118}
119
120/** While the pane is shown, re-exports a diagram saved from outside (VS Code, draw.io). */
121const pollChanges = async ($: EngineInterface) => {
122  try {
123    const isShown = (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
124    if (!isShown) return
125    for (const d of await read($, diagrams)) {
126      if (exporting.has(d.path) || d.status === 'no-drawio') continue
127      const { mtimeMs } = await $.fs.stat(d.path)
128      if (mtimeMs > d.mtimeMs) await refresh($, d.path)
129    }
130  } catch (error) {
131    $.ui.log(`${PLUGIN}: controllo dei file non riuscito: ${String(error)}`, { to: 'debug' })
132  }
133}
134
135/** `/drawio file.drawio`: shows the file, writing an empty diagram first when it does not exist. */
136const showFile = async ($: EngineInterface, arg: string): Promise<string> => {
137  if (!isDrawioPath(arg)) return `${USAGE} «${arg}» non finisce in .drawio.`
138  const isMissing = !(await $.fs.exists(arg))
139  if (isMissing) await $.fs.write(arg, EMPTY_DIAGRAM)
140  const path = (await $.fs.stat(arg, { resolve: true })).realPath ?? arg
141
142  await setDiagram($, path, d => ({ ...d, status: 'exporting' }))
143  await update($, selected, () => path)
144  await openPane($)
145  $.clock.after(0, () => void refresh($, path))
146
147  return isMissing ? `Creato ${arg}, un diagramma vuoto: chiedi a Claude di disegnarci qualcosa.` : `Aperto ${arg}.`
148}
149
150type Ran = { deny?: string; isError?: true }
151
152const actionsFor = ($: EngineInterface): Actions => ({
153  select: path => update($, selected, () => path),
154  turnPage: async (d, delta) => {
155    const page = Math.min(d.pageCount, Math.max(1, d.page + delta))
156    await setDiagram($, d.path, x => ({ ...x, page }))
157    await refresh($, d.path)
158  },
159  refresh: path => refresh($, path),
160  openInEditor: path => $.process.run(['code', path]),
161  openInDrawio: path => $.process.run(['open', '-a', 'draw.io', path]),
162  openPreview: png => $.process.run(['open', '-a', 'Preview', png]),
163  toggleView: () => update($, isTextView, isText => !isText),
164  close: async path => {
165    await update($, diagrams, list => list.filter(d => d.path !== path))
166    await update($, selected, () => null)
167  },
168})
169
170// A diagram Claude edits is shown and re-exported once the edit has run.
171const afterEdit = async ($: EngineInterface, path: string, ran: Ran) => {
172  if (ran.deny !== undefined || ran.isError === true || !isDrawioPath(path)) return
173  try {
174    await setDiagram($, path, d => ({ ...d, status: 'exporting' }))
175    await update($, selected, () => path)
176    await autoOpenOnce($)
177    // Exporting takes seconds: Claude gets the edit's answer now, the picture follows.
178    $.clock.after(0, () => void refresh($, path))
179  } catch (error) {
180    $.ui.log(`${PLUGIN}: anteprima non aggiornata: ${String(error)}`, { to: 'debug' })
181  }
182}
183
184export const register: Register = on => {
185  on('session.start', async ($, e, next) => {
186    await $.command.register({
187      name: 'drawio',
188      description: 'Mostra un diagramma draw.io nel pannello (lo crea se non esiste)',
189      argumentHint: '<file.drawio>',
190    })
191    $.clock.every(POLL_MS, () => void pollChanges($))
192
193    return next(e)
194  })
195
196  on('command.run', { command: 'drawio' }, async ($, e) => {
197    const arg = e.args.trim()
198    if (arg === '') {
199      await openPane($)
200      return { text: (await read($, diagrams)).length === 0 ? USAGE : 'Pannello draw.io aperto.' }
201    }
202    try {
203      return { text: await showFile($, arg) }
204    } catch (error) {
205      return { text: `Non riesco ad aprire ${arg}: ${String(error)}` }
206    }
207  })
208
209  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
210    const ran = await next(e)
211    await afterEdit($, e.file_path, ran)
212    return ran
213  }).catch(($, e, next) => next(e))
214
215  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
216    const ran = await next(e)
217    await afterEdit($, e.file_path, ran)
218    return ran
219  }).catch(($, e, next) => next(e))
220
221  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
222    const el = $.ui.resolve(e)
223    const list = await read($, diagrams)
224    const wanted = await read($, selected)
225    const current = list.find(d => d.path === wanted) ?? list[list.length - 1]
226    if (current === undefined) return emptyView(el)
227
228    const columns = Math.max(20, e.props.bodyColumns)
229    const rows = e.props.scroll.bodyRows
230    const isText = await read($, isTextView)
231    // Only the terminal paints cells; elsewhere the pane shows the drawing in characters.
232    const canShowImages = e.surface === 'terminal'
233    const blocked = await areImagesBlocked($)
234
235    return paneView(
236      el,
237      { diagrams: list, current, columns, rows, isTextView: isText, canShowImages, areImagesBlocked: blocked },
238      actionsFor($),
239    )
240  })
241}
242
hooks/diagram.ts 111 lines
1import type { Diagram } from '../types'
2
3/** draw.io Desktop's command line, as the brew cask installs it. */
4export const DRAWIO_BIN = '/Applications/draw.io.app/Contents/MacOS/draw.io'
5export const INSTALL_HINT = 'brew install --cask drawio'
6/** Export at twice the size: the terminal scales down, a sharper picture. */
7const EXPORT_SCALE = 2
8const EXPORT_BORDER = 16
9/** Most cells an Image may span either way. */
10const MAX_CELLS = 255
11/** A terminal cell is about twice as tall as it is wide. */
12const CELL_ASPECT = 2
13
14/** True for a file the pane previews: an uncompressed or compressed .drawio. */
15export const isDrawioPath = (path: string): boolean => /\.drawio$/i.test(path)
16
17/** Pages in a diagram file: one `<diagram>` element each, at least one. */
18export const pageCount = (xml: string): number => Math.max(1, (xml.match(/<diagram[\s/>]/g) ?? []).length)
19
20/** A diagram saved compressed keeps its pages as base64 text instead of `<mxGraphModel>` XML. */
21export const isCompressed = (xml: string): boolean => /<diagram[\s/>]/.test(xml) && !/<mxGraphModel[\s/>]/.test(xml)
22
23/** A safe file name for a diagram's preview: its path with every other character as `_`. */
24export const previewName = (path: string, page: number): string =>
25  `${path.replace(/^\/+/, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(-180)}-p${page}.png`
26
27/** The argv that has draw.io write one page of a diagram as PNG. */
28export const exportArgv = (path: string, page: number, out: string): string[] => [
29  DRAWIO_BIN,
30  '--export',
31  '--format',
32  'png',
33  '--scale',
34  String(EXPORT_SCALE),
35  '--border',
36  String(EXPORT_BORDER),
37  '--page-index',
38  String(page),
39  '--output',
40  out,
41  path,
42]
43
44/** Reads `sips -g pixelWidth -g pixelHeight` into the PNG's size, or null. */
45export const parseSips = (stdout: string): { width: number; height: number } | null => {
46  const width = Number(/pixelWidth:\s*(\d+)/.exec(stdout)?.[1])
47  const height = Number(/pixelHeight:\s*(\d+)/.exec(stdout)?.[1])
48  return width > 0 && height > 0 ? { width, height } : null
49}
50
51/**
52 * The cells a picture of `width`×`height` pixels spans to fit `columns`×`rows`
53 * without distortion, cells being about twice as tall as wide.
54 */
55export const fitImage = (
56  width: number,
57  height: number,
58  columns: number,
59  rows: number,
60): { columns: number; rows: number } => {
61  const ratio = height / width / CELL_ASPECT
62  const byWidth = Math.min(columns, MAX_CELLS)
63  const byHeight = Math.floor(Math.min(rows, MAX_CELLS) / ratio)
64  const cols = Math.max(1, Math.min(byWidth, byHeight))
65
66  return { columns: cols, rows: Math.max(1, Math.min(MAX_CELLS, Math.round(cols * ratio))) }
67}
68
69/** An empty, uncompressed diagram: what `/drawio new.drawio` writes for a file that does not exist. */
70export const EMPTY_DIAGRAM = [
71  '<mxfile host="drawio-viewer">',
72  '  <diagram id="page-1" name="Pagina 1">',
73  '    <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" page="1" pageWidth="827" pageHeight="1169">',
74  '      <root>',
75  '        <mxCell id="0" />',
76  '        <mxCell id="1" parent="0" />',
77  '      </root>',
78  '    </mxGraphModel>',
79  '  </diagram>',
80  '</mxfile>',
81  '',
82].join('\n')
83
84/** A diagram not exported yet. */
85export const newDiagram = (path: string): Diagram => ({
86  path,
87  page: 1,
88  pageCount: 1,
89  isCompressed: false,
90  mtimeMs: 0,
91  status: 'exporting',
92  error: null,
93  preview: null,
94  model: null,
95})
96
97/** Adds or replaces a diagram by path, keeping the list's order. Never mutates. */
98export const upsert = (list: readonly Diagram[], path: string, fn: (d: Diagram) => Diagram): Diagram[] =>
99  list.some(d => d.path === path)
100    ? list.map(d => (d.path === path ? fn(d) : d))
101    : [...list, fn(newDiagram(path))]
102
103/** The last line of a command's error output, short enough for the pane. */
104export const lastLine = (text: string): string =>
105  text.trim().split('\n').filter(Boolean).pop()?.slice(0, 200) ?? 'errore sconosciuto'
106
107const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1) || path
108
109/** The tab label: the file name without `.drawio`. */
110export const diagramLabel = (path: string): string => baseName(path).replace(/\.drawio$/i, '')
111
hooks/drawioxml.ts 155 lines
1import type { DiagramModel, DiagramEdge, DiagramVertex, VertexShape } from '../types'
2
3type Attrs = Record<string, string>
4type RawCell = { attrs: Attrs; body: string }
5
6const ENTITIES: Record<string, string> = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'", nbsp: ' ' }
7
8/** Decodes the XML entities draw.io writes in attribute values. */
9const decodeEntities = (text: string): string =>
10  text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code: string) => {
11    if (code[0] === '#') {
12      const n = code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10)
13      return Number.isFinite(n) ? String.fromCodePoint(n) : whole
14    }
15    return ENTITIES[code.toLowerCase()] ?? whole
16  })
17
18const parseAttrs = (text: string): Attrs =>
19  Object.fromEntries([...text.matchAll(/([\w:-]+)="([^"]*)"/g)].map(m => [m[1] ?? '', decodeEntities(m[2] ?? '')]))
20
21/** A label as plain text: HTML line breaks kept as newlines, every other tag dropped. */
22export const labelText = (value: string): string =>
23  decodeEntities(
24    value
25      .replace(/<br\s*\/?>/gi, '\n')
26      .replace(/<\/(div|p|li)>/gi, '\n')
27      .replace(/<[^>]+>/g, ''),
28  )
29    .split('\n')
30    .map(line => line.replace(/\s+/g, ' ').trim())
31    .filter(line => line !== '')
32    .join('\n')
33
34/** Reads `key=value;flag;` into a map; a bare word (the shape, e.g. `ellipse`) maps to "1". */
35export const parseStyle = (style: string): Attrs =>
36  Object.fromEntries(
37    style
38      .split(';')
39      .filter(Boolean)
40      .map(part => {
41        const at = part.indexOf('=')
42        return at < 0 ? [part, '1'] : [part.slice(0, at), part.slice(at + 1)]
43      }),
44  )
45
46/** The inside of each `<diagram>`: one string per page, in order. */
47export const pagesOf = (xml: string): string[] =>
48  [...xml.matchAll(/<diagram\b[^>]*>([\s\S]*?)<\/diagram>/g)].map(m => m[1] ?? '')
49
50/** Every cell, a `<UserObject>`/`<object>` wrapper's id and label folded into its inner `<mxCell>`. */
51const rawCells = (page: string): RawCell[] => {
52  const wrapped: RawCell[] = []
53  const rest = page.replace(/<(UserObject|object)\b([^>]*)>([\s\S]*?)<\/\1>/g, (_whole, _tag, outer: string, inner: string) => {
54    const wrapper = parseAttrs(outer)
55    const cell = /<mxCell\b([^>]*?)(?:\/>|>([\s\S]*?)<\/mxCell>)/.exec(inner)
56    const attrs = { ...parseAttrs(cell?.[1] ?? ''), id: wrapper.id ?? '', value: wrapper.label ?? wrapper.value ?? '' }
57    wrapped.push({ attrs, body: cell?.[2] ?? '' })
58    return ''
59  })
60  const plain = [...rest.matchAll(/<mxCell\b([^>]*?)(?:\/>|>([\s\S]*?)<\/mxCell>)/g)].map(m => ({
61    attrs: parseAttrs(m[1] ?? ''),
62    body: m[2] ?? '',
63  }))
64  return [...plain, ...wrapped]
65}
66
67const num = (value: string | undefined): number => {
68  const n = Number(value)
69  return Number.isFinite(n) ? n : 0
70}
71
72const color = (value: string | undefined): string | null =>
73  value === undefined || value === 'none' || value === 'default' || value === '' ? null : value
74
75const shapeOf = (style: Attrs): VertexShape => {
76  if (style.text === '1' || style.shape === 'text') return 'text'
77  if (style.ellipse === '1' || style.shape === 'ellipse') return 'ellipse'
78  if (style.rhombus === '1' || style.shape === 'rhombus') return 'rhombus'
79  return 'box'
80}
81
82const point = (body: string, as: string): { x: number; y: number } | null => {
83  const tag = [...body.matchAll(/<mxPoint\b([^>]*)\/?>/g)].map(m => parseAttrs(m[1] ?? '')).find(a => a.as === as)
84  return tag === undefined ? null : { x: num(tag.x), y: num(tag.y) }
85}
86
87/**
88 * One page's vertices and edges, positions absolute (a group's children moved
89 * by the group's corner), an edge's label children folded into its label.
90 */
91export const parseModel = (page: string): DiagramModel => {
92  const cells = rawCells(page)
93  const byId = new Map(cells.map(c => [c.attrs.id ?? '', c]))
94  const edgeIds = new Set(cells.filter(c => c.attrs.edge === '1').map(c => c.attrs.id ?? ''))
95
96  const corner = (id: string | undefined, depth = 0): { x: number; y: number } => {
97    const cell = id === undefined ? undefined : byId.get(id)
98    if (cell === undefined || cell.attrs.vertex !== '1' || depth > 20) return { x: 0, y: 0 }
99    const geo = parseAttrs(/<mxGeometry\b([^>]*)/.exec(cell.body)?.[1] ?? '')
100    const up = corner(cell.attrs.parent, depth + 1)
101    return { x: up.x + num(geo.x), y: up.y + num(geo.y) }
102  }
103
104  const edgeLabels = new Map<string, string>()
105  const vertices: DiagramVertex[] = cells
106    .filter(c => c.attrs.vertex === '1')
107    .flatMap(c => {
108      const label = labelText(c.attrs.value ?? '')
109      if (edgeIds.has(c.attrs.parent ?? '')) {
110        if (label !== '') edgeLabels.set(c.attrs.parent ?? '', label)
111        return []
112      }
113      const geo = parseAttrs(/<mxGeometry\b([^>]*)/.exec(c.body)?.[1] ?? '')
114      const style = parseStyle(c.attrs.style ?? '')
115      const at = corner(c.attrs.id)
116      return [
117        {
118          id: c.attrs.id ?? '',
119          label,
120          x: at.x,
121          y: at.y,
122          width: Math.max(num(geo.width), 1),
123          height: Math.max(num(geo.height), 1),
124          shape: shapeOf(style),
125          fill: color(style.fillColor),
126          stroke: color(style.strokeColor),
127          font: color(style.fontColor),
128          isRounded: style.rounded === '1',
129          isDashed: style.dashed === '1',
130          hasBorder: style.strokeColor !== 'none',
131        },
132      ]
133    })
134
135  const edges: DiagramEdge[] = cells
136    .filter(c => c.attrs.edge === '1')
137    .map(c => {
138      const style = parseStyle(c.attrs.style ?? '')
139      const own = labelText(c.attrs.value ?? '')
140      return {
141        id: c.attrs.id ?? '',
142        label: own !== '' ? own : (edgeLabels.get(c.attrs.id ?? '') ?? ''),
143        source: c.attrs.source ?? null,
144        target: c.attrs.target ?? null,
145        sourcePoint: point(c.body, 'sourcePoint'),
146        targetPoint: point(c.body, 'targetPoint'),
147        stroke: color(style.strokeColor),
148        isDashed: style.dashed === '1',
149        hasArrow: style.endArrow !== 'none',
150      }
151    })
152
153  return { vertices, edges }
154}
155
hooks/view.tsx 187 lines
1import type { ElementTable } from 'claude-code'
2
3import type { Diagram } from '../types'
4import { diagramLabel, fitImage, INSTALL_HINT } from './diagram'
5import { drawDiagram } from './textview'
6
7/** Rows the pane keeps for its own lines around the picture. */
8const CHROME_ROWS = 9
9const MIN_IMAGE_ROWS = 8
10const MAX_HOTKEYS = 9
11
12export type Actions = {
13  select: (path: string) => unknown
14  turnPage: (diagram: Diagram, delta: number) => unknown
15  refresh: (path: string) => unknown
16  openInEditor: (path: string) => unknown
17  openInDrawio: (path: string) => unknown
18  openPreview: (png: string) => unknown
19  toggleView: () => unknown
20  close: (path: string) => unknown
21}
22
23export type ViewState = {
24  diagrams: Diagram[]
25  current: Diagram
26  columns: number
27  rows: number
28  /** The drawing in characters instead of the PNG. */
29  isTextView: boolean
30  /** The surface draws pictures: the terminal does, the desktop app does not. */
31  canShowImages: boolean
32  /** A background session that Claude Code keeps from drawing pictures (see `areImagesBlocked`). */
33  areImagesBlocked: boolean
34}
35
36const IMAGES_HINT =
37  'Qui Claude Code spegne le immagini: per vedere il PNG aggiungi "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" in "env" di ~/.claude/settings.json e riavvia Claude.'
38
39const tabs = (el: ElementTable, s: ViewState, act: Actions) => (
40  <el.Box flexDirection="row" flexWrap="wrap" columnGap={1}>
41    <el.Text bold color="claude">◆ draw.io</el.Text>
42    {s.diagrams.map((d, i) => (
43      <el.Button
44        key={`tab:${d.path}`}
45        label={`${d.path === s.current.path ? '●' : '◇'} ${diagramLabel(d.path)}`}
46        hotkey={i < MAX_HOTKEYS ? String(i + 1) : undefined}
47        variant={d.path === s.current.path ? 'primary' : undefined}
48        dimColor={d.path !== s.current.path}
49        onPress={() => act.select(d.path)}
50      />
51    ))}
52  </el.Box>
53)
54
55const pageControls = (el: ElementTable, d: Diagram, act: Actions) =>
56  d.pageCount > 1 && (
57    <el.Box flexDirection="row" columnGap={1}>
58      <el.Button key="page-prev" label="◀" plain hotkey="p" onPress={() => act.turnPage(d, -1)} />
59      <el.Text dimColor>
60        pagina {d.page}/{d.pageCount}
61      </el.Text>
62      <el.Button key="page-next" label="▶" plain hotkey="n" onPress={() => act.turnPage(d, 1)} />
63    </el.Box>
64  )
65
66const statusLine = (el: ElementTable, d: Diagram, act: Actions) => {
67  if (d.status === 'no-drawio') {
68    return (
69      <el.Box flexDirection="column">
70        <el.Text color="warning">draw.io Desktop non è installato: senza, il diagramma si vede solo a caratteri, senza immagine né «Anteprima grande».</el.Text>
71        <el.Text>
72          <el.Text dimColor>Installalo con </el.Text>
73          <el.Text bold>{INSTALL_HINT}</el.Text>
74          <el.Text dimColor>, poi premi «Aggiorna».</el.Text>
75        </el.Text>
76      </el.Box>
77    )
78  }
79  if (d.status === 'failed') {
80    return (
81      <el.Box flexDirection="row" columnGap={1}>
82        <el.Text color="error">Anteprima non riuscita: {d.error ?? 'errore sconosciuto'}</el.Text>
83        <el.Button key="retry" label="Riprova" plain onPress={() => act.refresh(d.path)} />
84      </el.Box>
85    )
86  }
87  if (d.status === 'exporting') return <el.Text dimColor italic>Disegno l’anteprima…</el.Text>
88  return null
89}
90
91const textDrawing = (el: ElementTable, s: ViewState) => {
92  // A diagram saved by version 0.1 has no model yet: shown as not read until its next refresh.
93  const model = s.current.model ?? null
94  if (model === null) {
95    return s.current.isCompressed ? null : <el.Text dimColor italic>Leggo il diagramma…</el.Text>
96  }
97  const rows = drawDiagram(model, s.columns)
98  if (rows.length === 0) return <el.Text dimColor>Pagina vuota: chiedi a Claude di disegnarci qualcosa.</el.Text>
99
100  return (
101    <el.Box key="drawing" flexDirection="column">
102      {rows.map((row, r) => (
103        <el.Text key={`row:${r}`} wrap="truncate-end">
104          {row.length === 0
105            ? ' '
106            : row.map(span => (
107                <el.Text color={span.fg} backgroundColor={span.bg} bold={span.bold} dimColor={span.dim}>
108                  {span.text}
109                </el.Text>
110              ))}
111        </el.Text>
112      ))}
113    </el.Box>
114  )
115}
116
117const isPictureShown = (el: ElementTable, s: ViewState) =>
118  !s.isTextView && s.canShowImages && !s.areImagesBlocked && s.current.preview !== null && 'Image' in el
119
120const picture = (el: ElementTable, s: ViewState) => {
121  const preview = s.current.preview
122  if (preview === null || !('Image' in el)) return null
123  const size = fitImage(preview.width, preview.height, s.columns, Math.max(MIN_IMAGE_ROWS, s.rows - CHROME_ROWS))
124
125  return (
126    <el.Image
127      key="preview"
128      source={{ file: preview.png, format: 'png', generation: preview.generation }}
129      columns={size.columns}
130      rows={size.rows}
131      alt={`Diagramma ${diagramLabel(s.current.path)}, pagina ${s.current.page}`}
132    />
133  )
134}
135
136const actions = (el: ElementTable, d: Diagram, act: Actions, s: ViewState) => (
137  <el.Box flexDirection="row" columnGap={2}>
138    {d.preview !== null && (
139      <el.Button key="open-preview" label="Anteprima grande" hotkey="g" variant="primary" onPress={() => act.openPreview(d.preview?.png ?? '')} />
140    )}
141    <el.Button key="open-editor" label="Modifica in VS Code" hotkey="v" onPress={() => act.openInEditor(d.path)} />
142    {s.canShowImages && !s.areImagesBlocked && (
143      <el.Button key="toggle-view" label={s.isTextView ? 'Vista immagine' : 'Vista a caratteri'} hotkey="i" plain dimColor onPress={act.toggleView} />
144    )}
145    <el.Button key="open-drawio" label="Apri in draw.io" hotkey="d" plain onPress={() => act.openInDrawio(d.path)} />
146    <el.Button key="refresh" label="Aggiorna" hotkey="r" plain dimColor onPress={() => act.refresh(d.path)} />
147    <el.Button key="close" label="Chiudi" hotkey="x" plain dimColor onPress={() => act.close(d.path)} />
148  </el.Box>
149)
150
151/** The pane with at least one diagram. */
152export const paneView = (el: ElementTable, s: ViewState, act: Actions) => {
153  const d = s.current
154
155  return (
156    <el.Box flexDirection="column" gap={1}>
157      {tabs(el, s, act)}
158      <el.Box flexDirection="row" justifyContent="space-between">
159        <el.Text bold>{d.path.replace(/^\/Users\/[^/]+/, '~')}</el.Text>
160        {pageControls(el, d, act)}
161      </el.Box>
162      {d.isCompressed && (
163        <el.Text color="warning">
164          Diagramma salvato compresso: Claude non riesce a modificarlo bene. In draw.io togli «Compresso» da
165          File › Proprietà e salva.
166        </el.Text>
167      )}
168      {statusLine(el, d, act)}
169      {isPictureShown(el, s) ? picture(el, s) : textDrawing(el, s)}
170      {s.canShowImages && s.areImagesBlocked && <el.Text dimColor>{IMAGES_HINT}</el.Text>}
171      {actions(el, d, act, s)}
172      <el.Text dimColor>
173        Per modificarlo chiedi a Claude (es. «aggiungi un nodo Database collegato all’API») o usa VS Code: l’anteprima
174        si aggiorna da sola quando il file cambia.
175      </el.Text>
176    </el.Box>
177  )
178}
179
180/** The pane before any diagram. */
181export const emptyView = (el: ElementTable) => (
182  <el.Box flexDirection="column">
183    <el.Text bold color="claude">◆ draw.io</el.Text>
184    <el.Text dimColor>Nessun diagramma aperto. Usa /drawio percorso/file.drawio: se il file non esiste lo crea vuoto.</el.Text>
185  </el.Box>
186)
187
hooks/textview.ts 201 lines
1import type { DiagramModel, DiagramVertex } from '../types'
2
3/** One run of characters drawn in one style. */
4export type Span = { text: string; fg?: string; bg?: string; bold?: boolean; dim?: boolean }
5
6type Cell = { ch: string; fg?: string; bg?: string; bold?: boolean; dim?: boolean; lines: number }
7type Rect = { c0: number; r0: number; c1: number; r1: number }
8type Pt = { c: number; r: number }
9
10/** A terminal cell is about twice as tall as wide: rows take half the columns' scale. */
11const ROW_SCALE = 0.5
12const MAX_ROWS = 150
13const MIN_BOX_COLS = 5
14const MIN_BOX_ROWS = 3
15/** Text on a coloured fill: dark, as draw.io draws it by default. */
16const ON_FILL = '#1a1a1a'
17
18const UP = 1
19const DOWN = 2
20const LEFT = 4
21const RIGHT = 8
22const LINE: Record<number, string> = {
23  [UP]: '│', [DOWN]: '│', [UP | DOWN]: '│', [LEFT]: '─', [RIGHT]: '─', [LEFT | RIGHT]: '─',
24  [DOWN | RIGHT]: '╭', [DOWN | LEFT]: '╮', [UP | RIGHT]: '╰', [UP | LEFT]: '╯',
25  [UP | DOWN | RIGHT]: '├', [UP | DOWN | LEFT]: '┤', [DOWN | LEFT | RIGHT]: '┬', [UP | LEFT | RIGHT]: '┴',
26  [UP | DOWN | LEFT | RIGHT]: '┼',
27}
28const DASHED: Record<string, string> = { '│': '┆', '─': '┄' }
29
30const fit = (text: string, width: number): string =>
31  width <= 0 ? '' : text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
32
33/**
34 * Draws a diagram page with box-drawing characters in at most `columns`
35 * cells across: boxes at their scaled places with colours and labels,
36 * connectors routed at right angles with an arrowhead and their label.
37 */
38export const drawDiagram = (model: DiagramModel, columns: number): Span[][] => {
39  const shown = model.vertices
40  if (shown.length === 0) return []
41
42  const points = model.edges.flatMap(e => [e.sourcePoint, e.targetPoint]).filter(p => p !== null)
43  const minX = Math.min(...shown.map(v => v.x), ...points.map(p => p.x))
44  const minY = Math.min(...shown.map(v => v.y), ...points.map(p => p.y))
45  const maxX = Math.max(...shown.map(v => v.x + v.width), ...points.map(p => p.x))
46  const maxY = Math.max(...shown.map(v => v.y + v.height), ...points.map(p => p.y))
47  const cols = Math.max(20, columns)
48  const sx = (cols - 1) / Math.max(maxX - minX, 1)
49  const sy = sx * ROW_SCALE
50  const rows = Math.min(MAX_ROWS, Math.ceil((maxY - minY) * sy) + MIN_BOX_ROWS)
51  const grid: Cell[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ ch: ' ', lines: 0 })))
52
53  const inGrid = (p: Pt) => p.r >= 0 && p.r < rows && p.c >= 0 && p.c < cols
54  const at = (p: Pt): Cell | undefined => (inGrid(p) ? grid[p.r]?.[p.c] : undefined)
55  const toCell = (x: number, y: number): Pt => ({ c: Math.round((x - minX) * sx), r: Math.round((y - minY) * sy) })
56
57  const rects = new Map<string, Rect>(
58    shown.map(v => {
59      const a = toCell(v.x, v.y)
60      const b = toCell(v.x + v.width, v.y + v.height)
61      return [v.id, { c0: a.c, r0: a.r, c1: Math.min(cols - 1, Math.max(b.c, a.c + MIN_BOX_COLS - 1)), r1: Math.min(rows - 1, Math.max(b.r, a.r + MIN_BOX_ROWS - 1)) }]
62    }),
63  )
64  const isInBox = (p: Pt) => [...rects.values()].some(r => p.c >= r.c0 && p.c <= r.c1 && p.r >= r.r0 && p.r <= r.r1)
65
66  // Connectors first: boxes drawn after them cover any line that crosses a box.
67  const labels: { at: Pt; text: string }[] = []
68  for (const edge of model.edges) {
69    const route = routeEdge(edge.source === null ? null : (rects.get(edge.source) ?? null), edge.target === null ? null : (rects.get(edge.target) ?? null), edge.sourcePoint && toCell(edge.sourcePoint.x, edge.sourcePoint.y), edge.targetPoint && toCell(edge.targetPoint.x, edge.targetPoint.y))
70    if (route === null) continue
71    const style = { fg: edge.stroke ?? undefined, dim: edge.stroke === null }
72    for (let i = 1; i < route.path.length; i += 1) link(at, route.path[i - 1], route.path[i], style)
73    route.path.forEach(p => {
74      const cell = at(p)
75      if (cell === undefined) return
76      const ch = LINE[cell.lines] ?? cell.ch
77      cell.ch = edge.isDashed ? (DASHED[ch] ?? ch) : ch
78    })
79    const end = route.path[route.path.length - 1]
80    const endCell = end && at(end)
81    if (edge.hasArrow && endCell !== undefined) endCell.ch = route.arrow
82    if (edge.label !== '') labels.push({ at: route.labelAt, text: edge.label.replace(/\n/g, ' ') })
83  }
84
85  for (const v of shown) drawVertex(grid, v, rects.get(v.id))
86
87  for (const label of labels) {
88    const text = ` ${label.text} `
89    const start = Math.max(0, Math.min(cols - text.length, label.at.c - Math.floor(text.length / 2)))
90    ;[...text].forEach((ch, i) => {
91      const p = { c: start + i, r: label.at.r }
92      const cell = at(p)
93      if (cell !== undefined && !isInBox(p)) Object.assign(cell, { ch, fg: undefined, bg: undefined, dim: false, bold: false })
94    })
95  }
96
97  return grid.map(toSpans)
98}
99
100const link = (at: (p: Pt) => Cell | undefined, a: Pt | undefined, b: Pt | undefined, style: { fg?: string; dim: boolean }) => {
101  if (a === undefined || b === undefined) return
102  const from = at(a)
103  const to = at(b)
104  const [out, back] = b.r > a.r ? [DOWN, UP] : b.r < a.r ? [UP, DOWN] : b.c > a.c ? [RIGHT, LEFT] : [LEFT, RIGHT]
105  if (from !== undefined) Object.assign(from, { lines: from.lines | out, ...style })
106  if (to !== undefined) Object.assign(to, { lines: to.lines | back, ...style })
107}
108
109const centre = (r: Rect): Pt => ({ c: Math.round((r.c0 + r.c1) / 2), r: Math.round((r.r0 + r.r1) / 2) })
110
111/** Cells of a right-angled route from one box (or point) to another, its arrowhead and where its label goes. */
112const routeEdge = (s: Rect | null, t: Rect | null, sp: Pt | null, tp: Pt | null): { path: Pt[]; arrow: string; labelAt: Pt } | null => {
113  const a = s === null ? sp : centre(s)
114  const b = t === null ? tp : centre(t)
115  if (a === null || b === null) return null
116  // As draw.io lays out top-down: one box wholly above the other goes out the bottom, in the top.
117  const isStacked = s !== null && t !== null && (t.r0 > s.r1 || t.r1 < s.r0)
118  const isSideBySide = s !== null && t !== null && (t.c0 > s.c1 || t.c1 < s.c0)
119  const isVertical = isStacked || (!isSideBySide && Math.abs(b.r - a.r) * 2 >= Math.abs(b.c - a.c))
120
121  if (isVertical) {
122    const down = b.r >= a.r
123    const start = { c: a.c, r: s === null ? a.r : down ? s.r1 + 1 : s.r0 - 1 }
124    const end = { c: b.c, r: t === null ? b.r : down ? t.r0 - 1 : t.r1 + 1 }
125    const mid = Math.round((start.r + end.r) / 2)
126    return { path: orthogonal(start, end, mid, true), arrow: down ? '▼' : '▲', labelAt: { c: Math.round((start.c + end.c) / 2), r: mid } }
127  }
128  const right = b.c >= a.c
129  const start = { c: s === null ? a.c : right ? s.c1 + 1 : s.c0 - 1, r: a.r }
130  const end = { c: t === null ? b.c : right ? t.c0 - 1 : t.c1 + 1, r: b.r }
131  const mid = Math.round((start.c + end.c) / 2)
132  return { path: orthogonal(start, end, mid, false), arrow: right ? '▶' : '◀', labelAt: { c: mid, r: start.r === end.r ? start.r - 1 : Math.round((start.r + end.r) / 2) } }
133}
134
135/** start → bend at `mid` (a row when vertical, a column when not) → end, one cell per step. */
136const orthogonal = (start: Pt, end: Pt, mid: number, isVertical: boolean): Pt[] => {
137  const corners = isVertical
138    ? [start, { c: start.c, r: mid }, { c: end.c, r: mid }, end]
139    : [start, { c: mid, r: start.r }, { c: mid, r: end.r }, end]
140  return corners.reduce<Pt[]>((path, corner, i) => {
141    if (i === 0) return [corner]
142    const from = path[path.length - 1] ?? corner
143    const steps = Math.max(Math.abs(corner.c - from.c), Math.abs(corner.r - from.r))
144    const dc = Math.sign(corner.c - from.c)
145    const dr = Math.sign(corner.r - from.r)
146    return [...path, ...Array.from({ length: steps }, (_, k) => ({ c: from.c + dc * (k + 1), r: from.r + dr * (k + 1) }))]
147  }, [])
148}
149
150const drawVertex = (grid: Cell[][], v: DiagramVertex, r: Rect | undefined) => {
151  if (r === undefined) return
152  const isRound = v.isRounded || v.shape === 'ellipse'
153  const [tl, tr, bl, br] = isRound ? ['╭', '╮', '╰', '╯'] : ['┌', '┐', '└', '┘']
154  const h = v.isDashed ? '┄' : '─'
155  const vert = v.isDashed ? '┆' : '│'
156  const border = v.shape !== 'text' && v.hasBorder
157  const fill = v.shape === 'text' ? undefined : (v.fill ?? undefined)
158
159  for (let row = r.r0; row <= r.r1; row += 1) {
160    for (let col = r.c0; col <= r.c1; col += 1) {
161      const cell = grid[row]?.[col]
162      if (cell === undefined) continue
163      const isEdgeRow = row === r.r0 || row === r.r1
164      const isEdgeCol = col === r.c0 || col === r.c1
165      if (border && (isEdgeRow || isEdgeCol)) {
166        const ch = row === r.r0 ? (col === r.c0 ? tl : col === r.c1 ? tr : h) : row === r.r1 ? (col === r.c0 ? bl : col === r.c1 ? br : h) : vert
167        Object.assign(cell, { ch, fg: v.stroke ?? undefined, bg: undefined, dim: false, bold: false })
168      } else {
169        Object.assign(cell, { ch: ' ', fg: undefined, bg: fill, dim: false, bold: false })
170      }
171    }
172  }
173
174  const prefix = v.shape === 'rhombus' ? '◇ ' : ''
175  const inner = Math.max(1, r.c1 - r.c0 - (border ? 1 : -1))
176  const lines = `${prefix}${v.label}`.split('\n').filter(Boolean)
177  const top = r.r0 + Math.max(border ? 1 : 0, Math.floor((r.r1 - r.r0 + 1 - lines.length) / 2))
178  lines.forEach((line, i) => {
179    const row = top + i
180    if (row >= r.r1 + (border ? 0 : 1)) return
181    const text = fit(line, inner)
182    const start = r.c0 + Math.max(border ? 1 : 0, Math.floor((r.c1 - r.c0 + 1 - text.length) / 2))
183    ;[...text].forEach((ch, k) => {
184      const cell = grid[row]?.[start + k]
185      if (cell !== undefined) Object.assign(cell, { ch, fg: v.font ?? (fill ? ON_FILL : undefined), bg: fill, bold: i === 0 })
186    })
187  })
188}
189
190const sameStyle = (a: Span, c: Cell) => a.fg === c.fg && a.bg === c.bg && Boolean(a.bold) === Boolean(c.bold) && Boolean(a.dim) === Boolean(c.dim)
191
192/** A grid row as runs of one style, trailing blanks dropped. */
193const toSpans = (row: Cell[]): Span[] => {
194  const last = row.reduce((end, c, i) => (c.ch !== ' ' || c.bg !== undefined ? i : end), -1)
195  return row.slice(0, last + 1).reduce<Span[]>((spans, c) => {
196    const prev = spans[spans.length - 1]
197    if (prev !== undefined && sameStyle(prev, c)) return [...spans.slice(0, -1), { ...prev, text: prev.text + c.ch }]
198    return [...spans, { text: c.ch, fg: c.fg, bg: c.bg, bold: c.bold, dim: c.dim }]
199  }, [])
200}
201
types/index.d.ts 76 lines
1/** A rendered page of a diagram: the PNG draw.io wrote and its size in pixels. */
2export type Preview = {
3  png: string
4  width: number
5  height: number
6  /** Bumped on every export, so the terminal reads the same path again. */
7  generation: number
8}
9
10export type VertexShape = 'box' | 'ellipse' | 'rhombus' | 'text'
11
12/** A shape of the diagram, its position absolute in diagram pixels. */
13export type DiagramVertex = {
14  id: string
15  label: string
16  x: number
17  y: number
18  width: number
19  height: number
20  shape: VertexShape
21  fill: string | null
22  stroke: string | null
23  font: string | null
24  isRounded: boolean
25  isDashed: boolean
26  hasBorder: boolean
27}
28
29/** A connector between two shapes, or between loose points. */
30export type DiagramEdge = {
31  id: string
32  label: string
33  source: string | null
34  target: string | null
35  sourcePoint: { x: number; y: number } | null
36  targetPoint: { x: number; y: number } | null
37  stroke: string | null
38  isDashed: boolean
39  hasArrow: boolean
40}
41
42/** One page of a diagram, read from its XML: what the text view draws. */
43export type DiagramModel = { vertices: DiagramVertex[]; edges: DiagramEdge[] }
44
45/** Where a diagram's preview stands. */
46export type DiagramStatus = 'exporting' | 'ready' | 'failed' | 'no-drawio'
47
48/** A .drawio file shown in the pane. */
49export type Diagram = {
50  path: string
51  /** 1-based page shown. */
52  page: number
53  pageCount: number
54  /** Saved compressed: draw.io reads it, Claude cannot edit it reliably. */
55  isCompressed: boolean
56  /** The file's mtime at the last export, to notice a save from outside. */
57  mtimeMs: number
58  status: DiagramStatus
59  error: string | null
60  preview: Preview | null
61  /** The shown page read from the XML; null when compressed or unreadable. */
62  model: DiagramModel | null
63}
64
65declare module 'claude-code' {
66  interface PluginState {
67    'drawio-viewer': {
68      diagrams: Diagram[]
69      selected: string | null
70      hasAutoOpened: boolean
71      /** The drawing from the XML in characters, instead of the PNG. */
72      isTextView: boolean
73    }
74  }
75}
76