Skip to content

Commit 44f7f52

Browse files
authored
Merge pull request #53 from aniongithub/feat/image-support
feat: image support across wiki, MCP, HTTP, web UI, and sync
2 parents 30a82cc + 6a10c81 commit 44f7f52

29 files changed

Lines changed: 3964 additions & 57 deletions

.devcontainer/devcontainer.json

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,26 @@
1414
},
1515
"ghcr.io/devcontainers/features/sshd:1": {
1616
"version": "latest"
17+
},
18+
// Playwright + browsers + Linux runtime libs in one shot. The
19+
// feature's install.sh runs `npx playwright install --with-deps`
20+
// as the remote user, so the browser binaries land in
21+
// /home/vscode/.cache/ms-playwright/ and the apt-side runtime
22+
// libs (libnss3, libgbm1, etc.) are pulled in too. Reproducible
23+
// and dramatically simpler than maintaining the dep list in our
24+
// Dockerfile.
25+
"ghcr.io/schlich/devcontainer-features/playwright:0": {
26+
"browsers": "chromium"
1727
}
1828
},
29+
// Runs on the HOST before the container is created/started. Picks a
30+
// free TCP port, writes it to .devcontainer/ports.env. The container
31+
// runs with --network host (see runArgs) so the same port number is
32+
// both the in-container bind and the host-visible address — no port
33+
// forwarding involved. See the wiki page
34+
// [[preferences/devcontainer-ports]] for the full rationale and the
35+
// consumer recipes (launch.json, tasks.json, host scripts).
36+
"initializeCommand": ".devcontainer/initializeCommand.sh",
1937
"postCreateCommand": "go version && node --version",
2038
"postAttachCommand": "npm install --prefix webui",
2139
"customizations": {
@@ -26,7 +44,10 @@
2644
"ethan-reesor.vscode-go-test-adapter",
2745
"idered.npm",
2846
"qwtel.sqlite-viewer",
29-
"dbaeumer.vscode-eslint"
47+
"dbaeumer.vscode-eslint",
48+
// shellCommand.execute input type, used by launch.json to read
49+
// .devcontainer/ports.env into the Chrome launch URL.
50+
"augustocdias.tasks-shell-input"
3051
],
3152
"settings": {
3253
"go.buildTags": "",
@@ -36,7 +57,19 @@
3657
}
3758
}
3859
},
39-
"appPort": ["127.0.0.1:51888:4242"],
60+
// No appPort: --network host below means no port forwarding is
61+
// involved, so there's no host:container mapping to specify. The
62+
// server binds directly on the host's network namespace at whatever
63+
// port initializeCommand picked.
64+
//
65+
// --env-file injects MIND_MAP_HOST_PORT into the container so the
66+
// mind-map binary (which reads it via `serve --addr`) listens on
67+
// the right port. host-side consumers (launch.json, scripts) read
68+
// the same file directly.
69+
"runArgs": [
70+
"--network", "host",
71+
"--env-file", ".devcontainer/ports.env"
72+
],
4073
"portsAttributes": {
4174
"4242": {
4275
"label": "mind-map Server (devcontainer)",

.devcontainer/initializeCommand.sh

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env bash
2+
# Runs on the HOST (not in the container) before docker run. Picks a
3+
# free TCP port for the mind-map server and writes it to
4+
# .devcontainer/ports.env. The value is consumed in two places:
5+
#
6+
# 1. Inside the container, via runArgs --env-file, so the mind-map
7+
# binary's `serve --addr` reads $MIND_MAP_HOST_PORT.
8+
# 2. On the host, by tools (launch.json, the screenshot harness,
9+
# ad-hoc curl) that need to know what URL to hit.
10+
#
11+
# Because the container runs with --network host (see devcontainer.json
12+
# runArgs), there is no port forwarding involved — the container binds
13+
# directly to the host's network namespace, so the same port number is
14+
# the host port. That's why we don't need ${localEnv:...} substitution
15+
# in appPort (which doesn't work for values produced by
16+
# initializeCommand anyway, since appPort substitution happens before
17+
# initializeCommand runs).
18+
#
19+
# See: [[preferences/devcontainer-ports]] in the mind-map wiki for the
20+
# full pattern and the rationale.
21+
set -euo pipefail
22+
23+
# Preferred starting points. The value is stable across normal runs
24+
# when the preferred slot is free, so browser history / bookmarks /
25+
# muscle memory keep working. Only drifts when there's a real
26+
# collision (another worktree's devcontainer, a stray host process,
27+
# a VS Code port-forwarding daemon squatting on it).
28+
PREFERRED=(51888 51889 51890 51891 51892 51893)
29+
30+
pick_port() {
31+
local p
32+
for p in "${PREFERRED[@]}"; do
33+
if ! ss -tln "sport = :$p" 2>/dev/null | grep -q LISTEN; then
34+
echo "$p"
35+
return
36+
fi
37+
done
38+
# Kernel-assigned fallback. Bind a socket to port 0, read what we
39+
# got, close it. Tiny TOCTOU window before the container claims it;
40+
# in practice we don't hit it.
41+
python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()'
42+
}
43+
44+
PORT=$(pick_port)
45+
46+
# `cd` so the relative path is stable regardless of where the
47+
# devcontainer CLI was invoked from.
48+
cd "$(dirname "$0")"
49+
50+
# `--env-file` (used in runArgs) accepts bare KEY=VALUE lines. Don't
51+
# quote the value — docker chokes on that.
52+
cat > ports.env <<EOF
53+
MIND_MAP_HOST_PORT=$PORT
54+
EOF
55+
56+
echo "devcontainer host port: $PORT (written to .devcontainer/ports.env)"

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,9 @@ webui/node_modules/
1515
.vscode/cache/
1616
__debug_bin*
1717
*.exe
18+
19+
# Devcontainer host-port pick. Written by .devcontainer/initializeCommand.sh
20+
# at container-create time. Host-specific + per-run, so it must never be
21+
# committed. See the wiki page preferences/devcontainer-ports for the
22+
# pattern.
23+
.devcontainer/ports.env

.vscode/launch.json

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,19 @@
1212
],
1313
"configurations": [
1414
{
15+
// The devcontainer runs with --network host (see
16+
// .devcontainer/devcontainer.json), so the in-container
17+
// port IS the host port. initializeCommand.sh picks the
18+
// value at create time and writes it to
19+
// .devcontainer/ports.env; --env-file in runArgs makes
20+
// $MIND_MAP_HOST_PORT available in the container's env.
21+
// See [[preferences/devcontainer-ports]].
1522
"name": "mind-map Server",
1623
"type": "go",
1724
"request": "launch",
1825
"mode": "auto",
1926
"program": "${workspaceFolder}/cmd/mind-map",
20-
"args": ["serve", "--addr", "0.0.0.0:4242", "--webui", "${workspaceFolder}/webui/dist"],
27+
"args": ["serve", "--addr", "0.0.0.0:${env:MIND_MAP_HOST_PORT}", "--webui", "${workspaceFolder}/webui/dist"],
2128
"preLaunchTask": "build-webui"
2229
},
2330
{
@@ -26,7 +33,7 @@
2633
"request": "launch",
2734
"mode": "auto",
2835
"program": "${workspaceFolder}/cmd/mind-map",
29-
"args": ["serve", "--addr", "0.0.0.0:4242", "--webui", "${workspaceFolder}/webui/dist"],
36+
"args": ["serve", "--addr", "0.0.0.0:${env:MIND_MAP_HOST_PORT}", "--webui", "${workspaceFolder}/webui/dist"],
3037
},
3138
{
3239
"name": "mind-map (stdio)",
@@ -37,12 +44,16 @@
3744
"args": ["serve", "--stdio"],
3845
},
3946
{
47+
// Same port the server binds to (host == container under
48+
// --network host). The mindMapHostPort input reads
49+
// .devcontainer/ports.env so the URL tracks whatever value
50+
// initializeCommand picked.
4051
"name": "WebUI",
4152
"type": "chrome",
4253
"request": "launch",
4354
"browserLaunchLocation": "ui",
4455
"runtimeExecutable": "stable",
45-
"url": "http://localhost:51888",
56+
"url": "http://localhost:${input:mindMapHostPort}",
4657
"webRoot": "${workspaceFolder}/webui",
4758
"preLaunchTask": "waitForServer",
4859
"userDataDir": "${workspaceFolder}/.vscode/cache",
@@ -58,5 +69,25 @@
5869
"mode": "test",
5970
"program": "${workspaceFolder}/internal/wiki",
6071
}
72+
],
73+
"inputs": [
74+
{
75+
// Reads MIND_MAP_HOST_PORT out of .devcontainer/ports.env
76+
// every time the WebUI launch is invoked. Evaluated lazily,
77+
// so even if the container is rebuilt and the port changes
78+
// (because initializeCommand picked something else), the
79+
// next launch picks up the new value with no manual edit.
80+
//
81+
// Requires the augustocdias.tasks-shell-input extension,
82+
// which is declared in .devcontainer/devcontainer.json so
83+
// contributors get it automatically.
84+
"id": "mindMapHostPort",
85+
"type": "command",
86+
"command": "shellCommand.execute",
87+
"args": {
88+
"command": "grep ^MIND_MAP_HOST_PORT= ${workspaceFolder}/.devcontainer/ports.env | cut -d= -f2 | tr -d '\\n'",
89+
"useSingleResult": true
90+
}
91+
}
6192
]
6293
}

.vscode/tasks.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,19 @@
4545
"problemMatcher": ["$go"]
4646
},
4747
{
48+
// Probes the port the server is bound to. The container
49+
// runs with --network host (see devcontainer.json), so
50+
// the host and container see the same port number —
51+
// whatever value initializeCommand.sh wrote to
52+
// .devcontainer/ports.env and propagated into the
53+
// container via --env-file. Sourcing the file in the
54+
// shell command is the simplest portable way to read it;
55+
// it avoids the need for the tasks-shell-input extension
56+
// for tasks (the input is only used by launch.json's
57+
// Chrome URL). See [[preferences/devcontainer-ports]].
4858
"label": "waitForServer",
4959
"type": "shell",
50-
"command": "while ! nc -z localhost 4242; do sleep 1; done",
60+
"command": "set -a; source ${workspaceFolder}/.devcontainer/ports.env; set +a; while ! nc -z localhost \"$MIND_MAP_HOST_PORT\"; do sleep 1; done",
5161
"group": "none",
5262
"dependsOn": ["build-webui"],
5363
"problemMatcher": {

internal/config/config.go

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ type SyncMapping struct {
4141
Prefix string `json:"prefix"`
4242
Remote string `json:"remote"`
4343
Direction SyncDirection `json:"direction,omitempty"`
44+
// LFS, when true, configures the synced shadow clone to track
45+
// the patterns in LFSPatterns via git-lfs. Useful when binary
46+
// assets (uploaded via the image-support tools) would otherwise
47+
// balloon the git repo. Requires git-lfs on the host. Defaults
48+
// off because GitHub wikis don't support LFS — flip it on only
49+
// for plain repos / providers that do.
50+
LFS bool `json:"lfs,omitempty"`
51+
// LFSPatterns is the list of .gitattributes patterns to route
52+
// through LFS. If empty when LFS is true, a sensible default
53+
// (the browser-renderable image extensions plus .pdf) is used
54+
// — see DefaultLFSPatterns.
55+
LFSPatterns []string `json:"lfs_patterns,omitempty"`
56+
}
57+
58+
// DefaultLFSPatterns returns the default set of file patterns to route
59+
// through LFS when a sync mapping enables LFS but doesn't override the
60+
// patterns explicitly. Tracks the browser-renderable image set used by
61+
// the upload tools, plus common companion formats agents are likely to
62+
// reach for next.
63+
func DefaultLFSPatterns() []string {
64+
return []string{
65+
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp",
66+
"*.avif", "*.svg", "*.bmp", "*.ico",
67+
}
4468
}
4569

4670
// SyncConfig holds git sync settings.
@@ -90,7 +114,8 @@ func (s *SyncConfig) ResolveRemote(pagePath string) string {
90114
// If a mapping for prefix already exists, its remote and direction are
91115
// both replaced — this is treated as a re-registration, not an additive
92116
// op, so an existing mapping switching from bidirectional to pull-only
93-
// (or vice versa) propagates cleanly.
117+
// (or vice versa) propagates cleanly. LFS settings on an existing
118+
// mapping are preserved; use AddMappingWithLFS to update them.
94119
func (s *SyncConfig) AddMapping(prefix, remote string, direction SyncDirection) {
95120
direction = direction.Normalize()
96121
for i, m := range s.Mappings {
@@ -103,6 +128,34 @@ func (s *SyncConfig) AddMapping(prefix, remote string, direction SyncDirection)
103128
s.Mappings = append(s.Mappings, SyncMapping{Prefix: prefix, Remote: remote, Direction: direction})
104129
}
105130

131+
// AddMappingWithLFS is like AddMapping but also sets the LFS flag and
132+
// (optionally) the LFS patterns. Patterns default to DefaultLFSPatterns
133+
// when LFS is true and patterns is nil. Pass an empty (non-nil) slice
134+
// to explicitly track nothing — that's a usable no-op state for an
135+
// operator who wants to flip LFS on later.
136+
func (s *SyncConfig) AddMappingWithLFS(prefix, remote string, direction SyncDirection, lfs bool, patterns []string) {
137+
direction = direction.Normalize()
138+
if lfs && patterns == nil {
139+
patterns = DefaultLFSPatterns()
140+
}
141+
for i, m := range s.Mappings {
142+
if m.Prefix == prefix {
143+
s.Mappings[i].Remote = remote
144+
s.Mappings[i].Direction = direction
145+
s.Mappings[i].LFS = lfs
146+
s.Mappings[i].LFSPatterns = patterns
147+
return
148+
}
149+
}
150+
s.Mappings = append(s.Mappings, SyncMapping{
151+
Prefix: prefix,
152+
Remote: remote,
153+
Direction: direction,
154+
LFS: lfs,
155+
LFSPatterns: patterns,
156+
})
157+
}
158+
106159
// Remotes returns all unique remotes (default + mappings).
107160
func (s *SyncConfig) Remotes() []string {
108161
seen := make(map[string]bool)

0 commit comments

Comments
 (0)