Skip to content

Real SVG icons, and Kubernetes manifests identified by content - #46

Merged
cjimti merged 2 commits into
mainfrom
feat/38-icon-set
Aug 6, 2026
Merged

Real SVG icons, and Kubernetes manifests identified by content#46
cjimti merged 2 commits into
mainfrom
feat/38-icon-set

Conversation

@cjimti

@cjimti cjimti commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes #38. First of #37's eight children (#39#45 follow).

The tree drew its file types as ▸ ◆ ▤ ▫ and the editor tabs as coloured CSS squares — four buckets for every file in a Kubernetes manifest repository, which is to say a manifest, a Helm chart and a CI workflow all looked identical. This replaces them with a real SVG set, and decides what a YAML file is by reading it rather than by guessing from its directory.

1. Two icon sources, resolved differently on licensing

lucide-react (ISC) — a real dependency. Zero runtime dependencies, a React peer only, and it tree-shakes: the built bundle contains chevron-right and ellipsis-vertical and does not contain air-vent, zap or sailboat, which was checked against the artifact rather than assumed. Its icons draw in currentColor, which is what UI chrome needs — a close button has to go muted → foreground on hover, and an <img> cannot do that.

material-icon-theme (MIT) — vendored, not depended on. Seventeen SVGs copied verbatim into frontend/src/icons/material/ with the upstream LICENSE and a README recording version and provenance.

Not taking it as a dependency is a deliberate rejection. It is a VS Code extension, we import none of its code, and depending on it drags chroma-js into the production npm graph — whose declared license is the compound expression (BSD-3-Clause AND Apache-2.0). Both halves are on the allowlist, but license-checker-rseidelsohn --onlyAllow matches declared strings, so that is a gate failure waiting to happen on behalf of a package nothing here calls.

vscode-icons was rejected outright, as #38 said it would be: CC BY-SA.

Kustomize has no artwork in the set (kusto in there is Azure Data Explorer, a different product), so kustomization.yaml uses lucide's stacked layers. Worth flagging that DESIGN.md defers kustomize support to v1.x — an icon is not support, and the ticket asked for it.

Both sources sit behind one module. FileIcon takes an IconKind, UiIcon takes an action name ("close", not "an X"), and the tree, both tab strips and the changes rows all call the same two. FILE_SVG is typed Record<Exclude<IconKind, "kustomize">, string>, so adding a bucket to the union without artwork behind it fails type-checking rather than rendering nothing.

2. Kubernetes manifests are identified by content

The first commit answered this from the path: any YAML below the repository root that was not a chart, kustomization or workflow. That was wrong in the way a rule about icons should never be — it asserts something about a file it has not read. docker-compose.yaml two directories down got a Kubernetes wheel, and the only reason codecov.yml did not was a second guess layered on the first about root-level YAML being configuration.

The second commit replaces it. iconKind can no longer return "kubernetes" at all, and there is a test aimed squarely at the rule it replaces. Names now settle only what names can settle:

Rule Icon
.github/workflows/** GitHub Actions — that is where GitHub looks
Chart.yaml, Chart.lock, values*, under templates/ Helm — Helm named them
kustomization.y(a)ml Kustomize
every other .yaml/.yml plain YAML, until its content says otherwise

The upgrade is lazy, and its shape is the point. A listing renders immediately with the icons names earn; then useFileTree reads the head of that directory's plain-YAML files and the manifests among them change icon. Nothing blocks on a file read to draw a directory, and nothing is read outside the directories actually open.

internal/watch.ReadPrefixes is the read: the first 2 KiB of each named file, batched per directory. It returns evidence rather than a verdict on purpose — deciding in Go would put a second copy of the apiVersion/kind rule beside the frontend's. looksLikeManifest in tree.ts is the single implementation, used by the tree against a prefix and by the editor tab strip against the whole file it already holds, which is what keeps a manifest's tab and its tree row from disagreeing about the same file.

The rule is a column-0 apiVersion: and a column-0 kind:, both with values, anywhere in the head. Column 0 is what makes "top level" checkable without a YAML parser: a kind: under spec: is indented and a list item's never starts a line. Both keys are required, so a chart's values file carrying its own kind: is not promoted. Reading a prefix means this can only be wrong in one direction — a manifest whose two keys both sit past 2 KiB keeps the plain icon — and understating is the right way for an icon to fail.

Defects found by adversarial review, fixed here

Object-literal lookup returned inherited members. The icon tables were object literals, so BY_NAME["constructor"] answers "present" and returns Object — a Function handed back where an IconKind was promised, which TypeScript cannot catch because the index signature declares the value type. A file named constructor or toString would have rendered an icon with no artwork behind it. Both tables are Maps; the test over the five Object.prototype names fails against the previous version.

ReadPrefixes opened before it stat'd. Opening a named pipe blocks until something writes to it, and a repository is allowed to contain one — the call would have hung holding the batch, and the tree's icons with it. It Lstats first and takes regular files only, which also rejects symlinks (os.Root refuses to follow one out of the root but will follow one within it).

A UTF-8 BOM defeated the match. It sits in front of the first key, on the line most likely to carry apiVersion. Stripped.

The batch cap was reachable. 1024, so this binding cannot be turned into a repository walk — but a flat directory of two thousand manifests would have failed the call and kept the plain icon forever. The frontend batches at 256 rather than discovering the cap.

A project switch could cross-contaminate. An in-flight classification would have written one project's verdicts into another's tree, where paths are relative and deploy.yaml means different files. Checked against the current root before the state write.

Two pins raised, both by exactly one — these want your opinion

Per CLAUDE.md these need maintainer sign-off, so they are the thing to review first:

  • internal/watch 21 → 22 exported. One name, ReadPrefixes. The other two identifiers it needed (prefix length, oversized-batch sentinel) are unexported because nothing outside names them. ReadFile could not serve this: it answers "give me this file to edit" and enforces an editing size limit for it, which is the wrong contract for "tell me what these thirty files are".
  • maxAppMethods 23 → 24. One binding. It passes the test that ceiling's own comment sets — a request with an answer and no throughput, not a stream wearing a binding's clothes. The alternative was folding heads into ListDirectory's reply, which would make every expansion pay for reads it usually does not need, which is exactly the cost being avoided.

Both are argued at the pin, not only here.

Smaller decisions worth naming

  • assetsInlineLimit: 8192 in vite.config.ts. helm.svg is 4143 bytes and the 4096 default emitted it as a separate asset fetched through the Wails asset server on first paint, for a 4 KiB string that belongs in the bundle. icons.test.ts gates that every vendored icon stays under whatever the limit is, reading both numbers off disk — the same approach style.test.ts uses, and for the same reason: neither fact is visible to a rendering test, since vitest's dev transform serves assets as URLs and inlining is a property of vite build.
  • kindFromIcon was the trap. It maps IconKind to the bucket that picks the CodeMirror language. Splitting YAML five ways while leaving it matching only "yaml" would have silently opened every manifest and workflow as plain text, no highlighting and no folding. There is a YAML_ICONS set now and a comment saying what it holds up.
  • FileTree.tsx crossed the 600-line cap, so CreateRow/InlineField moved to their own module — the part of that file that knows nothing about trees. Shaving comments to fit a gate was the other option and is not one.
  • Buttons that lost their + gained an aria-label, since "file" alone is a worse accessible name than "+ file" was. Each label contains its visible word (WCAG 2.5.3).

Known consequence, stated rather than hidden

The changes rows show YAML as YAML. They have no content behind them, and wiring them to the tree's classifications would make a changed file's icon depend on whether its directory happened to be expanded — a panel inconsistent with itself is worse than one uniformly generic. #40 replaces that panel entirely.

Verification

make verify green, including bindings-check with the regenerated bindings committed.

  • 671 frontend tests, 0 lint issues (frontend and Go)
  • Go coverage: internal/app 93.0%, internal/watch 94.4%; patch floor clear
  • Licenses clean with the new dependency; real Wails smoke build; no dead code

Checked in the running app against a real manifest repository, not only in jsdom:

  • Seven .yml files under a manifests directory render as plain YAML and then upgrade to the Kubernetes wheel as their heads arrive — the async wiring unit tests cannot show
  • Folders open and close with the chevron; README.md opens in preview with the pencil offering edit; the dirty dot fills
  • A Kubernetes tab keeps YAML highlighting — kindFromIcon confirmed end to end
  • Both themes crisp at the 22px row height

One gap: patch-coverage is a Go-only gate, so the frontend half of this diff is covered by frontend-test and frontend-lint rather than by a measured patch percentage.

cjimti added 2 commits August 5, 2026 23:10
The tree drew its file types as `▸ ◆ ▤ ▫` and the editor tabs as coloured
CSS squares. Four buckets for every file in a Kubernetes manifest
repository, which is to say a manifest and a Helm chart and a CI workflow
all looked identical. This is the first of #37's eight children; the
others are #39-#45.

Two icon sources, and they resolved differently on licensing.

lucide-react (ISC) is a real dependency: zero runtime dependencies, a
React peer only, and it tree-shakes — the bundle carries chevron-right and
ellipsis-vertical and does not carry air-vent, zap or sailboat, which was
checked against the built artifact rather than assumed. It draws in
currentColor, which is what UI chrome needs: a close button has to go from
muted to foreground on hover, and an <img> cannot do that.

material-icon-theme (MIT) is vendored — seventeen SVGs copied verbatim
into frontend/src/icons/material/ with the upstream LICENSE and a README
recording version and provenance. It is NOT taken as a dependency, and
that is a deliberate rejection rather than an oversight: it is a VS Code
extension, we import none of its code, and depending on it drags chroma-js
into the production npm graph, whose declared licence is the compound
expression `(BSD-3-Clause AND Apache-2.0)`. Both halves are on the
allowlist, but license-checker-rseidelsohn --onlyAllow matches declared
strings, so that is a gate failure waiting to happen on behalf of a
package nothing here calls. vscode-icons was rejected outright, as the
ticket said it would be: CC BY-SA.

Kustomize has no artwork in the set — `kusto` in there is Azure Data
Explorer, a different product — so kustomization.yaml gets lucide's
stacked layers. Worth flagging that DESIGN.md defers kustomize support to
v1.x; an icon is not support, and the ticket asked for it.

Both sources sit behind one module. FileIcon takes an IconKind, UiIcon
takes an action name ("close", not "an X"), and the tree, both tab strips
and the changes rows all call the same two. FILE_SVG is typed
Record<Exclude<IconKind, "kustomize">, string>, so adding a bucket to the
union without artwork behind it fails type-checking rather than rendering
nothing.

iconKind moves from four extension buckets to seventeen path-based ones,
and the Kubernetes rule is a judgment call that should be reviewed as one.
Content sniffing is what would actually identify a manifest, and the
ticket rules it out for the right reason: apiVersion+kind cannot be read
for every entry in a directory the moment it is expanded without turning
one click into several hundred file reads. So: .github/workflows/** is
Actions; kustomization.y(a)ml is Kustomize; Chart.yaml, Chart.lock,
values* and anything under a templates/ segment are Helm; a dotfile or
YAML sitting at the repository root is plain YAML, because that is where
repository configuration lives and a Kubernetes wheel on codecov.yml
would be a lie; and everything else below the root is a manifest, because
that is what these repositories contain. docker-compose.yaml two
directories down is the case this gets wrong, and it takes the generic
icon back once sniffing exists.

kindFromIcon was the trap in this change and is the reason it is not
cosmetic. It maps IconKind to the bucket that picks the CodeMirror
language. Splitting YAML five ways while leaving that function matching
only "yaml" would have silently opened Chart.yaml, every manifest and
every workflow as plain text with no highlighting and no folding. There
is a YAML_ICONS set now and a comment saying what it is holding up.

Reviewing the diff for ways to break it found one defect in it. The lookup
tables were object literals, so BY_NAME["constructor"] answers "present"
and returns Object — a Function handed back where an IconKind was
promised, which TypeScript cannot catch because the index signature
declares the value type. A file named `constructor` or `toString` would
have rendered an icon with no artwork behind it. Both tables are Maps, and
the test over the five Object.prototype names fails against the previous
version.

Two smaller decisions worth naming:

- vite.config.ts sets assetsInlineLimit: 8192. helm.svg is 4143 bytes and
  the 4096 default emitted it as a separate asset the app fetches through
  the Wails asset server on first paint, for a 4 KiB string that belongs
  in the bundle. icons.test.ts gates that every vendored icon stays under
  whatever the limit is set to, reading both numbers off disk — the same
  approach style.test.ts uses, and for the same reason: neither fact is
  visible to a rendering test, since vitest's dev transform serves assets
  as URLs and inlining is a property of `vite build`.
- FileTree.tsx crossed the 600-line cap, so CreateRow and InlineField
  moved to their own module. They are the part of that file that knows
  nothing about trees, only a depth and two callbacks. Shaving comments to
  fit a gate would have been the other option and is not one.

Buttons that lost their "+" gained an aria-label, since "file" alone is a
worse accessible name than "+ file" was. Each label contains its visible
word (WCAG 2.5.3).

Verified: make verify green on this diff. 647 frontend tests, 0 lint
issues, licences clean with the new dependency, real Wails smoke build, no
dead code. The GUI is verified this time rather than skipped — run against
a real manifest repository under `wails dev`: nested .yml shows the
Kubernetes wheel and root YAML does not, folders open and close, README.md
opens in preview with the pencil offering edit, the dirty dot fills, and
the Kubernetes tab keeps YAML highlighting, which is kindFromIcon
confirmed end to end. Both themes checked at the 22px row height.

One gap stated plainly: patch-coverage is a Go-only gate and this diff is
entirely frontend, so the >=85% figure was not measured. frontend-test and
frontend-lint are the gates that ran.

Closes #38
The first commit shipped the icon set with a placeholder for the hardest
question in it: which YAML files are Kubernetes manifests. It answered
from the path — any YAML below the repository root that was not a chart,
a kustomization or a workflow. That rule is wrong in a way a rule about
icons should never be: it asserts something about a file it has not read.
A docker-compose.yaml two directories down got a Kubernetes wheel, and the
only reason `codecov.yml` did not was a second guess layered on the first
about root-level YAML being configuration.

Now the name decides only what a name can decide, and the content decides
the rest.

`iconKind` can no longer return "kubernetes" at all — there is a test that
says so, aimed squarely at the rule this replaces. Its YAML branch keeps
the two cases a path genuinely settles: `.github/workflows/**` is a
workflow because that is where GitHub looks for one, and `Chart.yaml`,
`values*` and `templates/` are Helm because Helm named them. Everything
else is plain YAML, wherever it sits. The root-and-dotfile special case is
gone with the rule it was propping up.

The upgrade is lazy, and the shape of it is the point. A listing renders
immediately with the icons names earn; then `useFileTree` reads the head
of that directory's plain-YAML files and the manifests among them change
icon. Nothing blocks on a file read to draw a directory, and nothing is
read outside the directories actually open.

internal/watch.ReadPrefixes is what it reads with: the first 2 KiB of each
named file, batched per directory. It returns evidence rather than a
verdict on purpose — deciding in Go would put a second copy of the
apiVersion/kind rule beside the frontend's, and `looksLikeManifest` in
tree.ts is then the single implementation, used by the tree against a
prefix and by the editor tab strip against the whole file it already
holds. That is what keeps a manifest's tab and its tree row from
disagreeing about the same file.

The rule is a column-0 `apiVersion:` and a column-0 `kind:`, both with
values, anywhere in the head. Column 0 is what makes "top level" checkable
without a YAML parser: a `kind:` under `spec:` is indented and a list
item's never starts a line. Both keys are required, so a chart's values
file carrying its own `kind:` is not promoted. Reading a prefix rather
than a file means this can only be wrong in one direction — a manifest
whose two keys both sit past 2 KiB keeps the plain icon — and understating
is the right way for an icon to fail.

Four things found by reviewing this for ways to break it, all fixed here:

- ReadPrefixes opened before it stat'd. Opening a named pipe blocks until
  something writes to it, and a repository is allowed to contain one: the
  call would have hung holding the batch and the tree's icons with it. It
  Lstats first and takes regular files only, which also rejects symlinks —
  os.Root refuses to follow one *out* of the root but will follow one
  within it.
- A UTF-8 BOM sits in front of the first key and defeats a column-0 match
  on the one line most likely to carry apiVersion. Stripped.
- The batch cap (1024, so this binding cannot be turned into a repository
  walk) was reachable: a flat directory of two thousand manifests would
  have failed the call and kept the plain icon forever. The frontend
  batches at 256 rather than discovering the cap.
- A project switch during an in-flight classification would have written
  one project's verdicts into another's tree, where paths are relative and
  `deploy.yaml` means different files. Checked against the current root
  before the state write.

Two pins move, both by exactly one, both wanting a maintainer's opinion:

- internal/watch 21 -> 22 exported. One name, ReadPrefixes. The other two
  identifiers it needed — the prefix length and the oversized-batch
  sentinel — are unexported, because nothing outside names them. ReadFile
  could not serve this: it answers "give me this file to edit" and enforces
  an editing size limit for it, which is the wrong contract for "tell me
  what these thirty files are".
- maxAppMethods 23 -> 24. One binding. It passes the test that ceiling's
  own comment sets — a request with an answer and no throughput, not a
  stream wearing a binding's clothes. The alternative was folding heads
  into ListDirectory's reply, which would make every expansion pay for
  reads it usually does not need, which is the cost being avoided.

Also in this diff: prefix_test.go joined the package's convention
(`package watch`, like every other test file here) and reuses the `write`
helper that was already there rather than the near-copy it arrived with.

One consequence stated rather than hidden: the changes rows show YAML as
YAML. They have no content behind them, and wiring them to the tree's
classifications would make a changed file's icon depend on whether its
directory happened to be expanded — a panel inconsistent with itself is
worse than one uniformly generic. #40 replaces that panel entirely.

Verified: make verify green. 671 frontend tests, Go coverage 93.0% app /
94.4% watch, patch floor clear, 0 lint issues, real Wails smoke build.
Checked in the running app against a real manifest repository: seven .yml
files under a manifests directory render as plain YAML and then upgrade to
the Kubernetes wheel as their heads arrive, which is the async wiring this
commit is mostly about and the part unit tests cannot show.
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.57143% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.43%. Comparing base (7ee400e) to head (b4e2a67).

Files with missing lines Patch % Lines
internal/watch/prefix.go 86.66% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #46      +/-   ##
==========================================
- Coverage   93.54%   93.43%   -0.12%     
==========================================
  Files          31       32       +1     
  Lines        1518     1553      +35     
==========================================
+ Hits         1420     1451      +31     
- Misses         62       64       +2     
- Partials       36       38       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cjimti
cjimti merged commit a36790c into main Aug 6, 2026
11 checks passed
@cjimti
cjimti deleted the feat/38-icon-set branch August 6, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UI: real SVG icon set (license-gated) replacing text glyphs

1 participant