diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 65fefe84..49749395 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.19.40" + ".": "0.19.41" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fcaf541..6c79745d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [0.19.41](https://github.com/kiwifs/kiwifs/compare/v0.19.40...v0.19.41) (2026-06-29) + + +### Features + +* bookmarks/highlights — colored text highlighting with toolbar panel ([#444](https://github.com/kiwifs/kiwifs/issues/444)) ([7706269](https://github.com/kiwifs/kiwifs/commit/7706269e76420a521e93bb2f9e5d9c6521dfa338)) +* **tree:** replace order metadata with natural sort ([#448](https://github.com/kiwifs/kiwifs/issues/448)) ([3b469fa](https://github.com/kiwifs/kiwifs/commit/3b469fae7259aa644cfca8c36e4d08dab3eb20bb)) +* **ui:** enhance widget components — AnnotationBar markdown, ArrayView sublabels, MatrixView triangular, GraphEdge labels ([#452](https://github.com/kiwifs/kiwifs/issues/452)) ([a8ac5da](https://github.com/kiwifs/kiwifs/commit/a8ac5da90e82b95d316400babe5c07b79824c59e)) + + +### Bug Fixes + +* always-mounted comments + kiwi-colored text selection ([4f50a8e](https://github.com/kiwifs/kiwifs/commit/4f50a8e2f556f599d17baaeaa5346c3c2a5de30f)) +* **ci:** auto-merge Cursor agent fix ([#453](https://github.com/kiwifs/kiwifs/issues/453)) ([2c3f5ab](https://github.com/kiwifs/kiwifs/commit/2c3f5ab5ee714de6ba7e13a2388e495b4186e819)) +* **tracker:** include problems in nested subfolders ([#449](https://github.com/kiwifs/kiwifs/issues/449)) ([abb8daa](https://github.com/kiwifs/kiwifs/commit/abb8daa52ab0ed65d0493f38bc24e4d8a2f04cda)) +* **ui:** MatrixView centerRows supports "start" alignment for staircase grids ([#454](https://github.com/kiwifs/kiwifs/issues/454)) ([9bcbb03](https://github.com/kiwifs/kiwifs/commit/9bcbb03eb11209b193a6dfc18ffebd2b8aa7e5b2)) +* **ui:** normalize relative paths in markdown anchor links ([#447](https://github.com/kiwifs/kiwifs/issues/447)) ([f344f2b](https://github.com/kiwifs/kiwifs/commit/f344f2bd0e750fec2b00f5287597dc9d1d781587)) +* **ui:** support SPA navigation for standard markdown .md#anchor links ([#446](https://github.com/kiwifs/kiwifs/issues/446)) ([11cfed9](https://github.com/kiwifs/kiwifs/commit/11cfed9b1ca4ca68a5628a7683f28cf5eebc8084)) +* **ui:** use correct /me/state route for local state persistence ([#450](https://github.com/kiwifs/kiwifs/issues/450)) ([40b0b30](https://github.com/kiwifs/kiwifs/commit/40b0b308cac9574aa3d479b731aaae9f078dafd6)) + + +### Reverts + +* undo post-0.19.40 changes (bookmarks, .me rename, docs) ([a2e6cab](https://github.com/kiwifs/kiwifs/commit/a2e6cab0634694e0ca2563f87dae089b8f4ae994)) + ## [0.19.40](https://github.com/kiwifs/kiwifs/compare/v0.19.39...v0.19.40) (2026-06-25) diff --git a/episodes/agents/cursor-issue-428/2026-06-30-hands-on-delivery.md b/episodes/agents/cursor-issue-428/2026-06-30-hands-on-delivery.md new file mode 100644 index 00000000..3d9406e4 --- /dev/null +++ b/episodes/agents/cursor-issue-428/2026-06-30-hands-on-delivery.md @@ -0,0 +1,31 @@ +--- +memory_kind: episodic +episode_id: cursor-issue-428-hands-on-2026-06-30 +title: Hands-on delivery for issue #428 keyboard shortcut overlay +tags: [frontend, keyboard-shortcuts, ui, issue-428, hands-on] +date: 2026-06-30 +--- + +## Task + +Deliver kiwifs/kiwifs#428 — searchable keyboard shortcut cheat sheet overlay with verified tests and PR. + +## Approach + +1. Rebased implementation onto `origin/main` (branch `feat/issue-428-keyboard-shortcuts-pr`) to avoid bundling calendar view (#427) changes. +2. Replaced static `Dialog` overlay with filterable `CommandDialog`. +3. Added plain `?` trigger with `isTextInputTarget` / `shouldOpenShortcutsHelp` guards. +4. Added HelpCircle toolbar button; custom bindings section via `getCustomShortcutItems`. + +## Tests + +```bash +cd ui && npm test -- --run kiwiKeybindings overlayDismiss +go test ./internal/keybindings/... -count=1 +``` + +18 UI tests passed; Go keybindings ok. + +## Branch + +`feat/issue-428-keyboard-shortcuts-pr` — pushed for PR against main. diff --git a/go.mod b/go.mod index f84549d6..f0cf1f39 100644 --- a/go.mod +++ b/go.mod @@ -105,6 +105,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/flopp/go-findfont v0.1.0 // indirect github.com/fogleman/gg v1.3.0 // indirect + github.com/fvbommel/sortorder v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index 82f21a60..fa82d348 100644 --- a/go.sum +++ b/go.sum @@ -154,6 +154,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= +github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index dd31352c..87a36229 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -110,7 +110,6 @@ type treeEntry struct { Name string `json:"name"` IsDir bool `json:"isDir"` Size int64 `json:"size,omitempty"` - Order *int `json:"order,omitempty"` Permalink string `json:"permalink,omitempty"` Children []*treeEntry `json:"children,omitempty"` } diff --git a/internal/api/handlers_file.go b/internal/api/handlers_file.go index c172f3d5..a2fe63d8 100644 --- a/internal/api/handlers_file.go +++ b/internal/api/handlers_file.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/json" "errors" "fmt" @@ -18,6 +17,7 @@ import ( "github.com/kiwifs/kiwifs/internal/config" "github.com/kiwifs/kiwifs/internal/markdown" "github.com/kiwifs/kiwifs/internal/pipeline" + "github.com/kiwifs/kiwifs/internal/preferences" "github.com/kiwifs/kiwifs/internal/search" "github.com/kiwifs/kiwifs/internal/storage" "github.com/kiwifs/kiwifs/internal/tracing" @@ -80,7 +80,6 @@ func toTreeEntry(st *storage.TreeEntry) *treeEntry { Name: st.Name, IsDir: st.IsDir, Size: st.Size, - Order: st.Order, } for _, c := range st.Children { e.Children = append(e.Children, toTreeEntry(c)) @@ -211,50 +210,6 @@ func pageViewSource(c echo.Context) string { return source } -type treeOrderWriter interface { - WriteTreeOrder(ctx context.Context, updates map[string]int) error -} - -type patchTreeOrderRequest struct { - Orders map[string]int `json:"orders"` -} - -func (h *Handlers) PatchTreeOrder(c echo.Context) error { - writer, ok := h.store.(treeOrderWriter) - if !ok { - return echo.NewHTTPError(http.StatusNotImplemented, "tree order metadata is not supported by this storage backend") - } - var req patchTreeOrderRequest - if err := bindJSON(c, &req); err != nil { - return err - } - if len(req.Orders) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "orders is required") - } - cleaned := make(map[string]int, len(req.Orders)) - for path, order := range req.Orders { - clean := strings.Trim(strings.TrimSpace(path), "/") - if clean == "" { - return echo.NewHTTPError(http.StatusBadRequest, "directory path is required") - } - st, err := h.store.Stat(c.Request().Context(), clean) - if err != nil { - return echo.NewHTTPError(http.StatusNotFound, err.Error()) - } - if !st.IsDir { - return echo.NewHTTPError(http.StatusBadRequest, "tree order path must be a directory") - } - cleaned[clean] = order - } - if err := writer.WriteTreeOrder(c.Request().Context(), cleaned); err != nil { - if errors.Is(err, storage.ErrPathDenied) { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - tracing.Record(c.Request().Context(), tracing.Event{Kind: tracing.KindWrite, Detail: "tree order patch"}) - return c.JSON(http.StatusOK, map[string]int{"updated": len(cleaned)}) -} type patchFrontmatterRequest struct { Fields map[string]any `json:"fields"` @@ -919,8 +874,10 @@ func (h *Handlers) RenameDir(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "from and to are required") } + updateLinks := c.QueryParam("update_links") != "false" actor := sanitizeActor(c.Request().Header.Get("X-Actor")) - count, err := h.pipe.RenameDir(c.Request().Context(), req.From, req.To, actor) + + res, err := h.pipe.RenameDirWithLinks(c.Request().Context(), req.From, req.To, actor, updateLinks) if err != nil { if errors.Is(err, storage.ErrPathDenied) { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) @@ -931,11 +888,15 @@ func (h *Handlers) RenameDir(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.JSON(http.StatusOK, map[string]any{ + resp := map[string]any{ "from": req.From, "to": req.To, - "renamed": count, - }) + "renamed": res.Renamed, + } + if len(res.UpdatedLinks) > 0 { + resp["updated_links"] = res.UpdatedLinks + } + return c.JSON(http.StatusOK, resp) } // AppendFile godoc @@ -1033,51 +994,65 @@ func (h *Handlers) DeleteFile(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"deleted": path}) } -// ReadLocalNote serves the companion .local/ for a given page path. -// This bypasses the hidden-dir filter intentionally — .local/ files are only -// accessible via this explicit endpoint and never through the standard file API. -func (h *Handlers) ReadLocalNote(c echo.Context) error { +// meDir resolves the personal state directory for the current user. +// If auth is configured and the request has a real actor identity, +// state is stored under .kiwi/users/{actor}/state/ (portable, per-user). +// Otherwise (no auth, single-user mode), it falls back to .me/ (device-only). +func (h *Handlers) meDir(c echo.Context) string { + actor := sanitizeActor(c.Request().Header.Get("X-Actor")) + if preferences.IsPersistableUser(actor) { + userID := preferences.UserID(actor) + if userID != "" { + return filepath.Join(h.root, ".kiwi", "users", userID, "state") + } + } + return filepath.Join(h.root, ".me") +} + +// ReadMyNote serves the companion personal note for a given page path. +// Resolves storage via meDir (auth-aware). +func (h *Handlers) ReadMyNote(c echo.Context) error { pagePath := c.QueryParam("path") if pagePath == "" { return echo.NewHTTPError(http.StatusBadRequest, "path is required") } base := filepath.Base(pagePath) - localPath := filepath.Join(h.root, ".local", base) + notePath := filepath.Join(h.meDir(c), base) - data, err := os.ReadFile(localPath) + data, err := os.ReadFile(notePath) if err != nil { if os.IsNotExist(err) { return c.NoContent(http.StatusNotFound) } - return echo.NewHTTPError(http.StatusInternalServerError, "failed to read local note") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to read note") } return c.Blob(http.StatusOK, "text/markdown; charset=utf-8", data) } -// GetLocalState reads a JSON state file from .local/.json. -func (h *Handlers) GetLocalState(c echo.Context) error { +// GetMyState reads a JSON state file for the current user. +func (h *Handlers) GetMyState(c echo.Context) error { name := c.QueryParam("name") if name == "" || strings.ContainsAny(name, "/\\..") { return echo.NewHTTPError(http.StatusBadRequest, "name is required and must be a simple identifier") } - localPath := filepath.Join(h.root, ".local", name+".json") - data, err := os.ReadFile(localPath) + statePath := filepath.Join(h.meDir(c), name+".json") + data, err := os.ReadFile(statePath) if err != nil { if os.IsNotExist(err) { return c.JSON(http.StatusOK, map[string]any{}) } - return echo.NewHTTPError(http.StatusInternalServerError, "failed to read local state") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to read state") } c.Response().Header().Set("Content-Type", "application/json; charset=utf-8") return c.Blob(http.StatusOK, "application/json; charset=utf-8", data) } -// PutLocalState writes a JSON state file to .local/.json. -func (h *Handlers) PutLocalState(c echo.Context) error { +// PutMyState writes a JSON state file for the current user. +func (h *Handlers) PutMyState(c echo.Context) error { name := c.QueryParam("name") if name == "" || strings.ContainsAny(name, "/\\..") { return echo.NewHTTPError(http.StatusBadRequest, "name is required and must be a simple identifier") @@ -1092,20 +1067,19 @@ func (h *Handlers) PutLocalState(c echo.Context) error { return echo.NewHTTPError(http.StatusRequestEntityTooLarge, "state exceeds 512 KB") } - // Validate it's valid JSON var check json.RawMessage if err := json.Unmarshal(body, &check); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "body must be valid JSON") } - localDir := filepath.Join(h.root, ".local") - if err := os.MkdirAll(localDir, 0o755); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "failed to create .local directory") + dir := h.meDir(c) + if err := os.MkdirAll(dir, 0o755); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to create state directory") } - localPath := filepath.Join(localDir, name+".json") - if err := os.WriteFile(localPath, body, 0o644); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "failed to write local state") + statePath := filepath.Join(dir, name+".json") + if err := os.WriteFile(statePath, body, 0o644); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to write state") } return c.NoContent(http.StatusNoContent) diff --git a/internal/api/handlers_metadata_test.go b/internal/api/handlers_metadata_test.go index 2bac7f6f..8ac7ffac 100644 --- a/internal/api/handlers_metadata_test.go +++ b/internal/api/handlers_metadata_test.go @@ -367,22 +367,15 @@ func TestPatchFrontmatterRejectsNonMarkdown(t *testing.T) { } } -func TestPatchTreeOrderUpdatesDirectoryOrder(t *testing.T) { +func TestTreeNaturalSortOrder(t *testing.T) { s := buildTestServer(t) - mustPutFile(t, s, "zeta/page.md", "# Zeta\n") - mustPutFile(t, s, "alpha/page.md", "# Alpha\n") + mustPutFile(t, s, "10-graphs/page.md", "# Graphs\n") + mustPutFile(t, s, "2-arrays/page.md", "# Arrays\n") + mustPutFile(t, s, "1-math/page.md", "# Math\n") - req := httptest.NewRequest(http.MethodPatch, "/api/kiwi/tree/order", strings.NewReader(`{"orders":{"zeta":1,"alpha":2}}`)) - req.Header.Set("Content-Type", "application/json") + req := httptest.NewRequest(http.MethodGet, "/api/kiwi/tree?path=/", nil) rec := httptest.NewRecorder() s.echo.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("PATCH tree order: %d %s", rec.Code, rec.Body.String()) - } - - req = httptest.NewRequest(http.MethodGet, "/api/kiwi/tree?path=/", nil) - rec = httptest.NewRecorder() - s.echo.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("GET tree: %d %s", rec.Code, rec.Body.String()) } @@ -390,7 +383,11 @@ func TestPatchTreeOrderUpdatesDirectoryOrder(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &tree); err != nil { t.Fatal(err) } - if len(tree.Children) < 2 || tree.Children[0].Name != "zeta" || tree.Children[1].Name != "alpha" { - t.Fatalf("directory order not reflected in tree: %#v", tree.Children) + if len(tree.Children) < 3 { + t.Fatalf("expected 3 children, got %d", len(tree.Children)) + } + if tree.Children[0].Name != "1-math" || tree.Children[1].Name != "2-arrays" || tree.Children[2].Name != "10-graphs" { + t.Fatalf("natural sort order wrong: got %s, %s, %s", + tree.Children[0].Name, tree.Children[1].Name, tree.Children[2].Name) } } diff --git a/internal/api/handlers_refactor.go b/internal/api/handlers_refactor.go new file mode 100644 index 00000000..7dd7046f --- /dev/null +++ b/internal/api/handlers_refactor.go @@ -0,0 +1,308 @@ +package api + +import ( + "context" + "net/http" + "strings" + + "github.com/kiwifs/kiwifs/internal/janitor" + "github.com/kiwifs/kiwifs/internal/links" + "github.com/kiwifs/kiwifs/internal/storage" + "github.com/labstack/echo/v4" +) + +type brokenLinkEntry struct { + Source string `json:"source"` + Target string `json:"target"` + Match string `json:"match,omitempty"` +} + +// BrokenLinks godoc +// +// @Summary List broken wiki links +// @Description Scans all pages and returns wiki links that do not resolve to any existing file. +// @Tags refactor +// @Security BearerAuth +// @Success 200 {object} map[string]any +// @Failure 500 {object} map[string]string +// @Router /api/kiwi/links/broken [get] +func (h *Handlers) BrokenLinks(c echo.Context) error { + scanner := janitor.New(h.root, h.store, h.searcher, 90) + result, err := scanner.Scan(c.Request().Context()) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + var broken []brokenLinkEntry + for _, issue := range result.Issues { + if issue.Kind != janitor.IssueBrokenLink { + continue + } + target := "" + if len(issue.Related) > 0 { + target = issue.Related[0] + } + if strings.HasSuffix(target, "\\") { + continue + } + match := fuzzyMatch(c.Request().Context(), h.store, target) + broken = append(broken, brokenLinkEntry{ + Source: issue.Path, + Target: target, + Match: match, + }) + } + if broken == nil { + broken = []brokenLinkEntry{} + } + + return c.JSON(http.StatusOK, map[string]any{ + "broken": broken, + "count": len(broken), + }) +} + +type orphanEntry struct { + Path string `json:"path"` +} + +// Orphans godoc +// +// @Summary List orphan pages +// @Description Returns pages with no inbound wiki links. +// @Tags refactor +// @Security BearerAuth +// @Success 200 {object} map[string]any +// @Failure 500 {object} map[string]string +// @Router /api/kiwi/links/orphans [get] +func (h *Handlers) Orphans(c echo.Context) error { + scanner := janitor.New(h.root, h.store, h.searcher, 90) + result, err := scanner.Scan(c.Request().Context()) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + var orphans []orphanEntry + for _, issue := range result.Issues { + if issue.Kind != janitor.IssueOrphan { + continue + } + orphans = append(orphans, orphanEntry{Path: issue.Path}) + } + if orphans == nil { + orphans = []orphanEntry{} + } + + return c.JSON(http.StatusOK, map[string]any{ + "orphans": orphans, + "count": len(orphans), + }) +} + +type fixResult struct { + Source string `json:"source"` + OldTarget string `json:"old_target"` + NewTarget string `json:"new_target"` +} + +type fixBrokenRequest struct { + DryRun bool `json:"dry_run"` +} + +// FixBrokenLinks godoc +// +// @Summary Auto-fix broken wiki links +// @Description Scans for broken wiki links and attempts to fix them by fuzzy-matching targets against existing files. Pass dry_run=true to preview without writing. +// @Tags refactor +// @Security BearerAuth +// @Param body body fixBrokenRequest true "Options" +// @Success 200 {object} map[string]any +// @Failure 500 {object} map[string]string +// @Router /api/kiwi/refactor/fix-broken [post] +func (h *Handlers) FixBrokenLinks(c echo.Context) error { + var req fixBrokenRequest + if err := bindJSON(c, &req); err != nil { + req.DryRun = c.QueryParam("dry_run") == "true" + } + + ctx := c.Request().Context() + scanner := janitor.New(h.root, h.store, h.searcher, 90) + result, err := scanner.Scan(ctx) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + // Group broken links by source file. + type pendingFix struct { + OldTarget string + NewTarget string + } + bySource := make(map[string][]pendingFix) + var fixes []fixResult + + for _, issue := range result.Issues { + if issue.Kind != janitor.IssueBrokenLink { + continue + } + if len(issue.Related) == 0 { + continue + } + target := issue.Related[0] + if strings.HasSuffix(target, "\\") { + continue + } + match := fuzzyMatch(ctx, h.store, target) + if match == "" { + continue + } + newTarget := strings.TrimSuffix(match, ".md") + bySource[issue.Path] = append(bySource[issue.Path], pendingFix{ + OldTarget: target, + NewTarget: newTarget, + }) + fixes = append(fixes, fixResult{ + Source: issue.Path, + OldTarget: target, + NewTarget: match, + }) + } + + if fixes == nil { + fixes = []fixResult{} + } + + if !req.DryRun { + actor := sanitizeActor(c.Request().Header.Get("X-Actor")) + for sourcePath, pending := range bySource { + content, rerr := h.store.Read(ctx, sourcePath) + if rerr != nil { + continue + } + text := string(content) + changed := false + for _, fix := range pending { + rewritten, ok := links.RewriteLinks(text, fix.OldTarget, fix.NewTarget) + if ok { + text = rewritten + changed = true + } + } + if changed { + if _, werr := h.pipe.Write(ctx, sourcePath, []byte(text), actor); werr != nil { + continue + } + } + } + } + + return c.JSON(http.StatusOK, map[string]any{ + "fixes": fixes, + "count": len(fixes), + "dry_run": req.DryRun, + }) +} + +// fuzzyMatch tries to find an existing file matching a broken wiki-link target. +// It prefers exact path matches, then path-suffix matches (matching directory +// components from right to left), then bare basename matches. +func fuzzyMatch(ctx context.Context, store storage.Storage, target string) string { + if target == "" { + return "" + } + + targetClean := strings.TrimSuffix(target, ".md") + targetClean = strings.TrimSuffix(targetClean, "\\") + targetLower := strings.ToLower(targetClean) + + if targetLower == "" { + return "" + } + + base := targetClean + if i := strings.LastIndex(base, "/"); i >= 0 { + base = base[i+1:] + } + baseLower := strings.ToLower(base) + + // Bare "_index" with no directory component is ambiguous (every dir has one). + if baseLower == "_index" && !strings.Contains(targetClean, "/") { + return "" + } + if baseLower == "" { + return "" + } + + // Strip numeric prefix for fuzzy directory matching (e.g. "06-prefix-sum" → "prefix-sum"). + stripNumPrefix := func(s string) string { + parts := strings.SplitN(s, "-", 2) + if len(parts) == 2 && len(parts[0]) > 0 { + allDigit := true + for _, c := range parts[0] { + if c < '0' || c > '9' { + allDigit = false + break + } + } + if allDigit { + return parts[1] + } + } + return s + } + + // Build a "stripped" version of the target for fuzzy dir matching. + // e.g. "06-prefix-sum/_index" → "prefix-sum/_index" + targetParts := strings.Split(targetLower, "/") + var strippedTarget []string + for _, p := range targetParts { + strippedTarget = append(strippedTarget, stripNumPrefix(p)) + } + strippedTargetLower := strings.Join(strippedTarget, "/") + + type candidate struct { + path string + score int // higher is better: 4=exact, 3=suffix, 2=stripped-suffix, 1=basename + } + var best candidate + + _ = storage.Walk(ctx, store, "/", func(entry storage.Entry) error { + if entry.IsDir || !strings.HasSuffix(entry.Path, ".md") { + return nil + } + entryStem := strings.TrimSuffix(entry.Path, ".md") + entryStemLower := strings.ToLower(entryStem) + + score := 0 + if entryStemLower == targetLower { + score = 4 + } else if strings.HasSuffix(entryStemLower, "/"+targetLower) { + score = 3 + } else { + // Try stripped (numberless) suffix match. + entryParts := strings.Split(entryStemLower, "/") + var strippedEntry []string + for _, p := range entryParts { + strippedEntry = append(strippedEntry, stripNumPrefix(p)) + } + strippedEntryLower := strings.Join(strippedEntry, "/") + if strings.HasSuffix(strippedEntryLower, "/"+strippedTargetLower) || strippedEntryLower == strippedTargetLower { + score = 2 + } else { + entryBase := entryStem + if i := strings.LastIndex(entryBase, "/"); i >= 0 { + entryBase = entryBase[i+1:] + } + if strings.ToLower(entryBase) == baseLower && baseLower != "_index" { + score = 1 + } + } + } + + if score > best.score { + best = candidate{path: entry.Path, score: score} + } + return nil + }) + + return best.path +} diff --git a/internal/api/server.go b/internal/api/server.go index 8d01f1dd..c65cabd3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -505,14 +505,13 @@ func (s *Server) setupRoutes() { api.GET("/changes", h.Changes) api.GET("/tree", h.Tree) api.GET("/file", h.ReadFile) - api.GET("/local-note", h.ReadLocalNote) - api.GET("/local-state", h.GetLocalState) - api.PUT("/local-state", h.PutLocalState) + api.GET("/me/note", h.ReadMyNote) + api.GET("/me/state", h.GetMyState) + api.PUT("/me/state", h.PutMyState) api.GET("/readlink", h.Readlink) api.PUT("/file", h.WriteFile) api.PATCH("/file", h.PatchFile) api.PATCH("/file/frontmatter", h.PatchFrontmatter) - api.PATCH("/tree/order", h.PatchTreeOrder) api.DELETE("/file", h.DeleteFile) api.POST("/rename", h.RenameFile) api.POST("/rename-dir", h.RenameDir) @@ -587,6 +586,9 @@ func (s *Server) setupRoutes() { api.POST("/analytics/content-gaps/dismiss", h.AnalyticsDismissContentGap) api.GET("/analytics/sources", h.AnalyticsSources) api.POST("/lint", h.Lint) + api.GET("/links/broken", h.BrokenLinks) + api.GET("/links/orphans", h.Orphans) + api.POST("/refactor/fix-broken", h.FixBrokenLinks) api.GET("/health-check", h.HealthCheck) api.GET("/context", h.Context) api.GET("/rules", h.Rules) diff --git a/internal/links/rewrite.go b/internal/links/rewrite.go index 5daf5d22..a118692a 100644 --- a/internal/links/rewrite.go +++ b/internal/links/rewrite.go @@ -52,6 +52,10 @@ func rewriteLinksInLine(line string, oldForms map[string]bool, newTarget string) return match } target := strings.TrimSpace(sub[1]) + // Skip escaped-pipe links (e.g. [[target\|label]] in tables). + if strings.HasSuffix(target, "\\") { + return match + } if !matchesTarget(target, oldForms) { return match } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index c44bd25b..d1078d9f 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -1124,43 +1124,113 @@ func (p *Pipeline) RenameWithLinks(ctx context.Context, oldPath, newPath, actor return Result{Path: newPath, ETag: etag}, updatedPaths, nil } +// RenameDirResult holds the outcome of a directory rename with link updates. +type RenameDirResult struct { + Renamed int `json:"renamed"` + UpdatedLinks []string `json:"updated_links,omitempty"` +} + // RenameDir atomically renames a directory on disk and commits all affected // paths via git. Re-indexes all files under the new directory. func (p *Pipeline) RenameDir(ctx context.Context, from, to, actor string) (int, error) { + res, err := p.RenameDirWithLinks(ctx, from, to, actor, false) + if err != nil { + return 0, err + } + return res.Renamed, nil +} + +// RenameDirWithLinks renames a directory and optionally rewrites wiki-links +// in all pages that reference any file under the old directory. Each moved +// file's backlinks are looked up and [[old-path]] references are rewritten +// to the new path stem (matching RenameWithLinks behavior for single files). +func (p *Pipeline) RenameDirWithLinks(ctx context.Context, from, to, actor string, updateLinks bool) (RenameDirResult, error) { if from == "" || to == "" { - return 0, fmt.Errorf("both from and to are required") + return RenameDirResult{}, fmt.Errorf("both from and to are required") } if err := ctx.Err(); err != nil { - return 0, err + return RenameDirResult{}, err } actor = coalesce(actor) root := p.Store.AbsPath("") absFrom, err := storage.GuardPath(root, from) if err != nil { - return 0, err + return RenameDirResult{}, err } absTo, err := storage.GuardPath(root, to) if err != nil { - return 0, err + return RenameDirResult{}, err } info, err := os.Stat(absFrom) if err != nil { - return 0, fmt.Errorf("stat source: %w", err) + return RenameDirResult{}, fmt.Errorf("stat source: %w", err) } if !info.IsDir() { - return 0, fmt.Errorf("source is not a directory: %s", from) + return RenameDirResult{}, fmt.Errorf("source is not a directory: %s", from) + } + + // Collect backlinks for all files in the source dir BEFORE rename + // (the link index still knows the old paths). + type pathMapping struct{ Old, New string } + var mappingsPreCompute []pathMapping + if updateLinks && p.Linker != nil { + _ = filepath.Walk(absFrom, func(path string, fi os.FileInfo, werr error) error { + if werr != nil || fi.IsDir() { + return nil + } + rel, _ := filepath.Rel(root, path) + rel = filepath.ToSlash(rel) + fromNorm := strings.TrimSuffix(from, "/") + "/" + toNorm := strings.TrimSuffix(to, "/") + "/" + newRel := toNorm + strings.TrimPrefix(rel, fromNorm) + mappingsPreCompute = append(mappingsPreCompute, pathMapping{Old: rel, New: newRel}) + return nil + }) + } + + // Gather backlinks for each old path before we move anything. + type backlinkSet struct { + OldPath string + NewStem string + Entries []links.Entry + } + var blSets []backlinkSet + if updateLinks && p.Linker != nil { + movedSet := make(map[string]bool, len(mappingsPreCompute)) + for _, m := range mappingsPreCompute { + movedSet[m.Old] = true + } + for _, m := range mappingsPreCompute { + entries, berr := p.Linker.Backlinks(ctx, m.Old) + if berr != nil || len(entries) == 0 { + continue + } + newStem := strings.TrimSuffix(m.New, ".md") + if idx := strings.LastIndex(newStem, "/"); idx >= 0 { + newStem = newStem[idx+1:] + } + var external []links.Entry + for _, e := range entries { + if !movedSet[e.Path] { + external = append(external, e) + } + } + if len(external) > 0 { + blSets = append(blSets, backlinkSet{OldPath: m.Old, NewStem: newStem, Entries: external}) + } + } } p.writeMu.Lock() defer p.writeMu.Unlock() if err := os.MkdirAll(filepath.Dir(absTo), 0755); err != nil { - return 0, fmt.Errorf("mkdir parent: %w", err) + return RenameDirResult{}, fmt.Errorf("mkdir parent: %w", err) } if err := os.Rename(absFrom, absTo); err != nil { - return 0, fmt.Errorf("rename dir: %w", err) + return RenameDirResult{}, fmt.Errorf("rename dir: %w", err) } var newPaths []string @@ -1189,10 +1259,60 @@ func (p *Pipeline) RenameDir(ctx context.Context, from, to, actor string) (int, p.markInflight(op) } - allPaths := append(newPaths, oldPaths...) + // Rewrite backlinks in external pages (outside the moved dir). + type fileUpdate struct { + Path string + Content []byte + } + var linkUpdates []fileUpdate + updatedSet := make(map[string]bool) + if updateLinks && len(blSets) > 0 { + for _, bl := range blSets { + for _, entry := range bl.Entries { + src, rerr := p.Store.Read(ctx, entry.Path) + if rerr != nil { + continue + } + content := string(src) + rewritten, changed := links.RewriteLinks(content, bl.OldPath, bl.NewStem) + if changed { + if updatedSet[entry.Path] { + // Already queued — apply on top of the queued version. + for i, u := range linkUpdates { + if u.Path == entry.Path { + r2, _ := links.RewriteLinks(string(u.Content), bl.OldPath, bl.NewStem) + linkUpdates[i].Content = []byte(r2) + break + } + } + } else { + linkUpdates = append(linkUpdates, fileUpdate{Path: entry.Path, Content: []byte(rewritten)}) + updatedSet[entry.Path] = true + } + } + } + } + } + + for _, u := range linkUpdates { + p.markInflightEtag(u.Path, ETag(u.Content)) + if werr := p.Store.Write(ctx, u.Path, u.Content); werr != nil { + log.Printf("pipeline: RenameDirWithLinks write(%s): %v", u.Path, werr) + } + } + + var updatedPaths []string + for path := range updatedSet { + updatedPaths = append(updatedPaths, path) + } + + allPaths := append(append(newPaths, oldPaths...), updatedPaths...) msg := fmt.Sprintf("%s: rename dir %s → %s", actor, from, to) + if len(updatedPaths) > 0 { + msg += fmt.Sprintf(" (updated %d links)", len(updatedPaths)) + } if err := p.Versioner.BulkCommit(ctx, allPaths, actor, msg); err != nil { - log.Printf("pipeline: RenameDir BulkCommit: %v", err) + log.Printf("pipeline: RenameDirWithLinks BulkCommit: %v", err) for _, p2 := range allPaths { p.trackUncommitted(p2) } @@ -1207,6 +1327,9 @@ func (p *Pipeline) RenameDir(ctx context.Context, from, to, actor string) (int, for _, op := range oldPaths { p.deindexFile(ctx, op) } + for _, u := range linkUpdates { + p.indexFile(ctx, u.Path, u.Content) + } for _, np := range newPaths { p.broadcast(events.Event{Op: "write", Path: np, Actor: actor}) @@ -1215,7 +1338,7 @@ func (p *Pipeline) RenameDir(ctx context.Context, from, to, actor string) (int, p.broadcast(events.Event{Op: "delete", Path: op, Actor: actor}) } - return len(newPaths), nil + return RenameDirResult{Renamed: len(newPaths), UpdatedLinks: updatedPaths}, nil } // DeferredDelete records a deletion in git and removes from indexes, without diff --git a/internal/storage/local.go b/internal/storage/local.go index c66339fc..e2707ae0 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "context" - "encoding/json" "errors" "fmt" "os" @@ -20,92 +19,7 @@ import ( // should map this to HTTP 400 rather than 500. var ErrPathDenied = errors.New("path denied") -const treeOrderMetadataPath = ".kiwi/tree-order.json" -func (l *Local) treeOrderMetadataAbsPath() string { - return filepath.Join(l.root, filepath.FromSlash(treeOrderMetadataPath)) -} - -func (l *Local) readTreeOrderMap() (map[string]int, error) { - content, err := os.ReadFile(l.treeOrderMetadataAbsPath()) - if err != nil { - if os.IsNotExist(err) { - return map[string]int{}, nil - } - return nil, err - } - var orders map[string]int - if err := json.Unmarshal(content, &orders); err != nil { - return nil, err - } - if orders == nil { - orders = map[string]int{} - } - return orders, nil -} - -func (l *Local) ReadTreeOrder(_ context.Context, path string) (*int, error) { - clean := normalizeUserPath(strings.TrimSuffix(path, "/")) - if clean == "" { - return nil, nil - } - orders, err := l.readTreeOrderMap() - if err != nil { - return nil, err - } - if order, ok := orders[clean]; ok { - return &order, nil - } - return nil, nil -} - -func (l *Local) WriteTreeOrder(_ context.Context, updates map[string]int) error { - orders, err := l.readTreeOrderMap() - if err != nil { - return err - } - for rawPath, order := range updates { - clean := normalizeUserPath(strings.TrimSuffix(rawPath, "/")) - if clean == "" { - continue - } - if _, err := l.guardPath(clean); err != nil { - return err - } - orders[clean] = order - } - content, err := json.MarshalIndent(orders, "", " ") - if err != nil { - return err - } - content = append(content, '\n') - abs := l.treeOrderMetadataAbsPath() - if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { - return err - } - tmp, err := os.CreateTemp(filepath.Dir(abs), ".tree-order-*.json") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { - if tmpName != "" { - os.Remove(tmpName) - } - }() - if _, err := tmp.Write(content); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Rename(tmpName, abs); err != nil { - return err - } - tmpName = "" - return nil -} // Local implements Storage over a local directory. // diff --git a/internal/storage/tree.go b/internal/storage/tree.go index 610ab086..104bc3b8 100644 --- a/internal/storage/tree.go +++ b/internal/storage/tree.go @@ -4,10 +4,9 @@ import ( "context" "path/filepath" "sort" - "strconv" "strings" - "github.com/kiwifs/kiwifs/internal/markdown" + "github.com/fvbommel/sortorder" ) type TreeEntry struct { @@ -15,23 +14,14 @@ type TreeEntry struct { Name string `json:"name"` IsDir bool `json:"isDir"` Size int64 `json:"size,omitempty"` - Order *int `json:"order,omitempty"` FrontmatterError string `json:"frontmatterError,omitempty"` Children []*TreeEntry `json:"children,omitempty"` } -type frontmatterReader interface { - ReadFrontmatter(ctx context.Context, path string) (map[string]any, error) -} - type frontmatterErrorReader interface { ReadFrontmatterError(ctx context.Context, path string) (string, error) } -type treeOrderReader interface { - ReadTreeOrder(ctx context.Context, path string) (*int, error) -} - // BuildTree creates the recursive API tree and attaches order metadata. // // Directory order is read from the tree sidecar metadata; markdown order is @@ -75,18 +65,20 @@ func buildTreeChild(ctx context.Context, store Storage, entry Entry, depth int) Size: entry.Size, } - applyTreeOrderMetadata(ctx, store, child) + applyFrontmatterError(ctx, store, child) applyTreeChildren(ctx, store, child, depth) return child } -// applyTreeOrderMetadata attaches directory sidecar order or markdown frontmatter order. -func applyTreeOrderMetadata(ctx context.Context, store Storage, child *TreeEntry) { +// applyFrontmatterError attaches parse errors so the UI can warn about broken frontmatter. +func applyFrontmatterError(ctx context.Context, store Storage, child *TreeEntry) { if child.IsDir { - child.Order = readDirectoryOrder(ctx, store, child.Path) return } - child.Order, child.FrontmatterError = readOrderMetadata(ctx, store, child.Path) + if !isMarkdownPath(child.Path) { + return + } + child.FrontmatterError = readFrontmatterError(ctx, store, child.Path) } // applyTreeChildren recursively loads child rows for expandable directories. @@ -104,83 +96,21 @@ func applyTreeChildren(ctx context.Context, store Storage, child *TreeEntry, dep child.Children = sub.Children } -// sortTreeChildren orders explicit order metadata before falling back to names. +// sortTreeChildren sorts entries using natural (human/version) sort order +// so that "2-foo" comes before "10-bar". func sortTreeChildren(children []*TreeEntry) { sort.SliceStable(children, func(i, j int) bool { - a, b := children[i], children[j] - if a.Order != nil && b.Order != nil && *a.Order != *b.Order { - return *a.Order < *b.Order - } - if a.Order != nil && b.Order == nil { - return true - } - if a.Order == nil && b.Order != nil { - return false - } - return strings.ToLower(a.Name) < strings.ToLower(b.Name) + return sortorder.NaturalLess( + strings.ToLower(children[i].Name), + strings.ToLower(children[j].Name), + ) }) } -// readDirectoryOrder returns the persisted folder order when the backend supports it. -func readDirectoryOrder(ctx context.Context, store Storage, path string) *int { - reader, ok := store.(treeOrderReader) - if !ok { - return nil - } - order, err := reader.ReadTreeOrder(ctx, path) - if err != nil { - return nil - } - return order -} - -// readOrder preserves the older helper contract for callers that only need order. -func readOrder(ctx context.Context, store Storage, path string) *int { - order, _ := readOrderMetadata(ctx, store, path) - return order -} - -// readOrderMetadata reads markdown order and returns parse errors as display text. -func readOrderMetadata(ctx context.Context, store Storage, path string) (*int, string) { - if !isMarkdownPath(path) { - return nil, "" - } - reader, ok := store.(frontmatterReader) - if ok { - return readOrderFromFrontmatterReader(ctx, store, reader, path) - } - return readOrderFromContent(ctx, store, path) -} - -// isMarkdownPath reports whether order should come from markdown frontmatter. +// isMarkdownPath reports whether the path is a markdown file. func isMarkdownPath(path string) bool { lower := strings.ToLower(path) - if strings.HasSuffix(lower, ".md") { - return true - } - return strings.HasSuffix(lower, ".markdown") -} - -// readOrderFromFrontmatterReader uses storage-provided frontmatter parsing. -func readOrderFromFrontmatterReader(ctx context.Context, store Storage, reader frontmatterReader, path string) (*int, string) { - fm, err := reader.ReadFrontmatter(ctx, path) - if err != nil { - return nil, err.Error() - } - return frontmatterOrder(fm["order"]), readFrontmatterError(ctx, store, path) -} - -// readOrderFromContent parses frontmatter when the backend has no reader interface. -func readOrderFromContent(ctx context.Context, store Storage, path string) (*int, string) { - content, err := store.Read(ctx, path) - if err != nil { - return nil, "" - } - fm, err := markdown.Frontmatter(content) - if err != nil { - return nil, err.Error() - } - return frontmatterOrder(fm["order"]), "" + return strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") } // readFrontmatterError exposes cached parse errors when available. @@ -195,41 +125,3 @@ func readFrontmatterError(ctx context.Context, store Storage, path string) strin } return errText } - -// frontmatterOrder normalizes supported YAML scalar forms into an integer order. -func frontmatterOrder(v any) *int { - asInt, ok := v.(int) - if ok { - return &asInt - } - - asInt64, ok := v.(int64) - if ok { - n := int(asInt64) - return &n - } - - asFloat64, ok := v.(float64) - if ok { - return integralFloatOrder(asFloat64) - } - - asString, ok := v.(string) - if !ok { - return nil - } - n, err := strconv.Atoi(strings.TrimSpace(asString)) - if err != nil { - return nil - } - return &n -} - -// integralFloatOrder accepts JSON-decoded whole numbers and rejects fractions. -func integralFloatOrder(v float64) *int { - n := int(v) - if float64(n) != v { - return nil - } - return &n -} diff --git a/internal/storage/tree_order_test.go b/internal/storage/tree_test.go similarity index 67% rename from internal/storage/tree_order_test.go rename to internal/storage/tree_test.go index 44353ecc..5c28f09b 100644 --- a/internal/storage/tree_order_test.go +++ b/internal/storage/tree_test.go @@ -32,54 +32,40 @@ func (s *frontmatterOnlyStore) List(context.Context, string) ([]Entry, error) { }, nil } -func (s *frontmatterOnlyStore) ReadFrontmatter(_ context.Context, path string) (map[string]any, error) { - s.readFrontmatterCalls++ - if path == "b.md" { - return map[string]any{"order": 2}, nil - } - return map[string]any{"order": 1}, nil -} - -func TestBuildTreeReadsOrderFromFrontmatterOnlyWhenAvailable(t *testing.T) { +func TestBuildTreeSortsAlphabetically(t *testing.T) { store := &frontmatterOnlyStore{} tree, err := BuildTree(context.Background(), store, "/", 0) if err != nil { t.Fatalf("build tree: %v", err) } - if store.readCalls != 0 { - t.Fatalf("BuildTree used full Read %d time(s); expected frontmatter-only reads", store.readCalls) - } - if store.readFrontmatterCalls != 2 { - t.Fatalf("ReadFrontmatter calls = %d, want 2", store.readFrontmatterCalls) - } if got, want := tree.Children[0].Name, "a.md"; got != want { - t.Fatalf("first child = %s, want %s", got, want) + t.Fatalf("first child = %s, want %s (natural sort)", got, want) } } -func TestBuildTreeSortsDirectoriesByStoredOrder(t *testing.T) { +func TestBuildTreeNaturalSortDirectories(t *testing.T) { root := t.TempDir() store, err := NewLocal(root) if err != nil { t.Fatal(err) } - for _, dir := range []string{"zeta", "alpha"} { + for _, dir := range []string{"10-graphs", "2-arrays", "1-math"} { if err := os.Mkdir(filepath.Join(root, dir), 0755); err != nil { t.Fatal(err) } } - if err := store.WriteTreeOrder(context.Background(), map[string]int{"zeta": 1, "alpha": 2}); err != nil { - t.Fatal(err) - } tree, err := BuildTree(context.Background(), store, "", 1) if err != nil { t.Fatal(err) } - if len(tree.Children) < 2 { - t.Fatalf("expected directories, got %#v", tree.Children) + if len(tree.Children) < 3 { + t.Fatalf("expected 3 directories, got %d", len(tree.Children)) } - if tree.Children[0].Name != "zeta" || tree.Children[1].Name != "alpha" { - t.Fatalf("directory order = %s, %s; want zeta, alpha", tree.Children[0].Name, tree.Children[1].Name) + want := []string{"1-math", "2-arrays", "10-graphs"} + for i, w := range want { + if tree.Children[i].Name != w { + t.Fatalf("child[%d] = %s, want %s", i, tree.Children[i].Name, w) + } } } diff --git a/internal/webui/branding.go b/internal/webui/branding.go index 0a0c8989..1aa09323 100644 --- a/internal/webui/branding.go +++ b/internal/webui/branding.go @@ -3,20 +3,30 @@ package webui import ( "bytes" "strings" + "sync" "github.com/kiwifs/kiwifs/internal/config" ) -var branding config.BrandingConfig +var ( + brandingMu sync.RWMutex + branding config.BrandingConfig +) // SetBranding wires workspace branding for index.html injection at serve time. func SetBranding(b config.BrandingConfig) { + brandingMu.Lock() branding = b + brandingMu.Unlock() } func injectBranding(html []byte) []byte { - name := htmlEscape(branding.ResolvedName()) - favicon := htmlEscape(branding.ResolvedFaviconURL()) + brandingMu.RLock() + b := branding + brandingMu.RUnlock() + + name := htmlEscape(b.ResolvedName()) + favicon := htmlEscape(b.ResolvedFaviconURL()) out := bytes.Replace(html, []byte("KiwiFS"), []byte(""+name+""), 1) diff --git a/internal/webui/branding_test.go b/internal/webui/branding_test.go index 17411451..8a30003b 100644 --- a/internal/webui/branding_test.go +++ b/internal/webui/branding_test.go @@ -8,7 +8,7 @@ import ( ) func TestInjectBranding_Defaults(t *testing.T) { - branding = config.BrandingConfig{} + SetBranding(config.BrandingConfig{}) html := []byte(`KiwiFS `) @@ -22,10 +22,10 @@ func TestInjectBranding_Defaults(t *testing.T) { } func TestInjectBranding_Custom(t *testing.T) { - branding = config.BrandingConfig{ + SetBranding(config.BrandingConfig{ Name: "Acme KB", FaviconURL: ".kiwi/assets/favicon.svg", - } + }) html := []byte(`KiwiFS `) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d9cc252a..0e857f7e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,6 +3,7 @@ import { Clock4, Columns3, Database, + HelpCircle, LayoutGrid, Moon, Network, @@ -47,7 +48,13 @@ import { usePinnedPages } from "./hooks/usePinnedPages"; import { useKeybindings } from "./hooks/useKeybindings"; import { useUIConfig } from "./hooks/useUIConfig"; import { usePreferences } from "./hooks/usePreferences"; -import { formatChordDisplay, matchBoundAction, type KeybindingAction } from "./lib/kiwiKeybindings"; +import { + formatChordDisplay, + isTextInputTarget, + matchBoundAction, + shouldOpenShortcutsHelp, + type KeybindingAction, +} from "./lib/kiwiKeybindings"; import { resolveOverlayDismiss } from "./lib/overlayDismiss"; import { hasDeepLinkPath, resolveDashboardPath, resolveStartPage, shouldApplyStartPage } from "./lib/startPage"; import { formatDocumentTitle } from "./lib/pageTitle"; @@ -179,7 +186,7 @@ export default function App() { const { recent, recordVisit } = useRecentPages(currentSpace); const { starred, toggle: toggleStar, isStarred } = useStarredPages(currentSpace); const { pinned, toggle: togglePin, isPinned } = usePinnedPages(currentSpace); - const { bindings, conflicts } = useKeybindings(); + const { bindings } = useKeybindings(); const { config: uiConfig, loaded: uiConfigLoaded } = useUIConfig(); const resolvedStartPage = resolveStartPage(uiConfig.startPage); const editorRef = useRef<{ save: () => Promise; toggleMode?: () => void } | null>(null); @@ -312,6 +319,13 @@ export default function App() { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.defaultPrevented) return; + + if (!isTextInputTarget(e) && shouldOpenShortcutsHelp(e)) { + e.preventDefault(); + setShortcutsOpen((v) => !v); + return; + } + const action = matchBoundAction(e, bindings); if (!action) return; @@ -733,6 +747,9 @@ const handleSpaceSwitch = useCallback(() => { }} /> + setShortcutsOpen(true)} label="Keyboard shortcuts (?)"> + + {!themeLocked && ( {theme === "dark" ? : } @@ -963,8 +980,6 @@ const handleSpaceSwitch = useCallback(() => { ); diff --git a/ui/src/components/KeyboardShortcuts.tsx b/ui/src/components/KeyboardShortcuts.tsx index 56589edb..6d261b5d 100644 --- a/ui/src/components/KeyboardShortcuts.tsx +++ b/ui/src/components/KeyboardShortcuts.tsx @@ -1,62 +1,98 @@ import { Keyboard } from "lucide-react"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@kw/components/ui/dialog"; + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@kw/components/ui/command"; +import { useKeybindings } from "../hooks/useKeybindings"; import { - formatChordDisplay, SHORTCUT_SECTIONS, + formatChordDisplay, + formatChordSegments, + getCustomShortcutItems, type KeybindingAction, } from "../lib/kiwiKeybindings"; type Props = { open: boolean; onOpenChange: (open: boolean) => void; - bindings: Record; - conflicts?: { chord: string; actions: string[] }[]; }; -export function KeyboardShortcuts({ open, onOpenChange, bindings, conflicts = [] }: Props) { +function ShortcutKeys({ action, bindings }: { action: KeybindingAction; bindings: Record }) { + const segments = formatChordSegments(bindings[action]); return ( - - - - - - Keyboard shortcuts - - - {conflicts.length > 0 && ( -
- Conflicting bindings detected:{" "} - {conflicts.map((c) => `${c.actions.join(" / ")} (${formatChordDisplay(c.chord)})`).join("; ")} -
- )} -
- {SHORTCUT_SECTIONS.map((s) => ( -
-
- {s.section} -
-
- {s.items.map((item) => ( -
- {item.label} - - {formatChordDisplay(bindings[item.action])} - -
- ))} -
-
- ))} + + {segments.map((segment) => ( + + {segment} + + ))} + + ); +} + +export function KeyboardShortcuts({ open, onOpenChange }: Props) { + const { bindings, defaults, conflicts } = useKeybindings(); + const customItems = getCustomShortcutItems(bindings, defaults); + + return ( + +
+ + Keyboard shortcuts +
+ {conflicts.length > 0 && ( +
+ Conflicting bindings:{" "} + {conflicts.map((c) => `${c.actions.join(" / ")} (${formatChordDisplay(c.chord)})`).join("; ")}
- -
+ )} + + + No matching shortcuts. + {SHORTCUT_SECTIONS.map((section) => ( + + {section.items.map((item) => ( + {}} + > + {item.label} + + + ))} + + ))} + {customItems.length > 0 && ( + + {customItems.map((item) => ( + {}} + > + {item.label} + + + ))} + + )} + + ); } diff --git a/ui/src/components/KiwiPage.tsx b/ui/src/components/KiwiPage.tsx index 4c5874e3..bd040e25 100644 --- a/ui/src/components/KiwiPage.tsx +++ b/ui/src/components/KiwiPage.tsx @@ -12,7 +12,7 @@ import Zoom from "react-medium-image-zoom"; import "react-medium-image-zoom/dist/styles.css"; import { AlertTriangle, BookOpen, Bug, Calendar, CheckCircle2, CheckSquare, ChevronDown, ChevronRight, CircleAlert, ClipboardList, Crosshair, Edit, Eye, File, FileAxis3D, FileQuestion, Flame, Folder, HelpCircle, History as HistoryIcon, Info, Lightbulb, Link2, List, ListChecks, MessageSquareQuote, NotebookPen, Pin, Plus, Quote, ScrollText, ShieldAlert, Star, Tag, TriangleAlert, Type, User, XCircle, Zap } from "lucide-react"; import { api, type TreeEntry } from "@kw/lib/api"; -import { dirOf, titleize } from "@kw/lib/paths"; +import { dirOf, normalizePath, titleize } from "@kw/lib/paths"; import { readingTime } from "@kw/lib/readingTime"; import { HostPageActions } from "./HostPageActions"; import { KiwiBreadcrumb } from "./KiwiBreadcrumb"; @@ -800,6 +800,54 @@ export function KiwiPage({ path, tree, onNavigate, onEdit, onHistory, onRevealIn ); } + if (h.startsWith("#")) { + return ( + { + e.preventDefault(); + const el = document.getElementById(h.slice(1)); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + history.replaceState(null, "", h); + } + }} + {...(rest as any)} + > + {children} + + ); + } + + const mdMatch = /^([^#]*\.md)(?:#(.+))?$/.exec(h); + if (mdMatch && !h.startsWith("http")) { + const relPage = mdMatch[1]; + const anchor = mdMatch[2] ?? ""; + const dir = dirOf(path); + const joined = dir ? `${dir}/${relPage}` : relPage; + const resolved = normalizePath(joined); + return ( + { + e.preventDefault(); + onNavigate(resolved); + if (anchor) { + requestAnimationFrame(() => { + setTimeout(() => { + const el = document.getElementById(anchor); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 150); + }); + } + }} + {...(rest as any)} + > + {children} + + ); + } + return ( ): FlatNode[] { - const children = (args.parentNode?.children?.map((child) => child.data) || rootNodes).slice(); - let insertAt = Math.max(0, Math.min(args.index, children.length)); - - for (const dragNode of args.dragNodes) { - const replacement = replacements?.get(dragNode.id); - const moving = replacement || dragNode.data; - children.splice(insertAt, 0, moving); - const originalIndex = children.findIndex((child, i) => i !== insertAt && child.id === dragNode.id); - if (originalIndex >= 0) { - children.splice(originalIndex, 1); - if (originalIndex < insertAt) insertAt -= 1; - } - insertAt += 1; - } - - return children; -} export const KiwiTree = forwardRef(function KiwiTree( { @@ -433,15 +414,12 @@ export const KiwiTree = forwardRef(function KiwiTree( try { if (cleanSrc === dest) { - await persistSiblingOrder(destinationChildrenAfterMove(args, data), api); onMoved?.("", { refresh: false }); return; } if (sourceNode.isDir) { await api.renameDir(cleanSrc, dest); - const movedNode: FlatNode = { ...sourceNode, id: dest, name: fileName }; - await persistSiblingOrder(destinationChildrenAfterMove(args, data, new Map([[src, movedNode]])), api); onMoved?.(dest); return; } @@ -450,9 +428,6 @@ export const KiwiTree = forwardRef(function KiwiTree( await api.writeFile(dest, content); await api.deleteFile(cleanSrc); pushOp({ type: "move", from: cleanSrc, to: dest, content }); - - const movedNode: FlatNode = { ...sourceNode, id: dest, name: fileName }; - await persistSiblingOrder(destinationChildrenAfterMove(args, data, new Map([[src, movedNode]])), api); onMoved?.(dest); } catch (e) { setRootPreservingScroll(previousRoot); diff --git a/ui/src/components/ui/command.tsx b/ui/src/components/ui/command.tsx index 9566fe74..ec982cf0 100644 --- a/ui/src/components/ui/command.tsx +++ b/ui/src/components/ui/command.tsx @@ -24,6 +24,7 @@ interface CommandDialogProps extends React.ComponentProps { className?: string; contentClassName?: string; commandProps?: React.ComponentPropsWithoutRef; + title?: string; } const CommandDialog = ({ @@ -31,6 +32,7 @@ const CommandDialog = ({ className, contentClassName, commandProps, + title = "Search", ...props }: CommandDialogProps) => ( @@ -38,7 +40,7 @@ const CommandDialog = ({ className={cn("overflow-hidden p-0 sm:max-w-2xl", contentClassName)} showCloseButton={false} > - Search + {title} >(name: string): Promise { const qs = new URLSearchParams({ name }); - const res = await fetch(`${kiwiBase()}/local-state?${qs}`, { + const res = await fetch(`${kiwiBase()}/me/state?${qs}`, { headers: { "X-Actor": actor(), ..._extraHeaders }, }); if (!res.ok) return {} as T; @@ -382,7 +381,7 @@ export const api = { async putLocalState(name: string, state: unknown): Promise { const qs = new URLSearchParams({ name }); - await fetch(`${kiwiBase()}/local-state?${qs}`, { + await fetch(`${kiwiBase()}/me/state?${qs}`, { method: "PUT", headers: { "Content-Type": "application/json", "X-Actor": actor(), ..._extraHeaders }, body: JSON.stringify(state), @@ -426,14 +425,6 @@ export const api = { }); }, - async patchTreeOrder(orders: Record): Promise<{ updated: number }> { - return request(`${kiwiBase()}/tree/order`, { - method: "PATCH", - headers: { "Content-Type": "application/json", "X-Actor": actor(), ..._extraHeaders }, - body: JSON.stringify({ orders }), - }); - }, - async deleteFile(path: string): Promise<{ deleted: string }> { const qs = new URLSearchParams({ path }); return request(`${kiwiBase()}/file?${qs}`, { method: "DELETE" }); diff --git a/ui/src/lib/kiwiKeybindings.test.ts b/ui/src/lib/kiwiKeybindings.test.ts index ec8f4c94..5984173e 100644 --- a/ui/src/lib/kiwiKeybindings.test.ts +++ b/ui/src/lib/kiwiKeybindings.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DEFAULT_KEYBINDINGS, buildChordIndex, eventMatchesChord, formatChordDisplay, + formatChordSegments, + getCustomShortcutItems, + isTextInputTarget, matchBoundAction, + shouldOpenShortcutsHelp, mergeKeybindings, normalizeChord, } from "./kiwiKeybindings"; @@ -101,3 +105,103 @@ describe("formatChordDisplay", () => { expect(formatChordDisplay("mod+k")).toMatch(/K/i); }); }); + +describe("formatChordSegments", () => { + it("shows ⌘ on macOS and Ctrl elsewhere", () => { + vi.stubGlobal("navigator", { platform: "MacIntel" }); + expect(formatChordSegments("mod+k")).toEqual(["⌘", "K"]); + vi.stubGlobal("navigator", { platform: "Win32" }); + expect(formatChordSegments("mod+k")).toEqual(["Ctrl", "K"]); + vi.unstubAllGlobals(); + }); +}); + +describe("getCustomShortcutItems", () => { + it("lists only bindings that differ from defaults", () => { + const bindings = mergeKeybindings({ + bindings: { search: "mod+j", save: "mod+s" }, + defaults: DEFAULT_KEYBINDINGS, + conflicts: [], + }); + const custom = getCustomShortcutItems(bindings, DEFAULT_KEYBINDINGS); + expect(custom.map((item) => item.action)).toEqual(["search"]); + }); +}); + +describe("isTextInputTarget", () => { + it("detects native inputs and editor surfaces", () => { + class MockHTMLElement { + tagName = ""; + isContentEditable = false; + closest(_selector: string): MockHTMLElement | null { + return null; + } + } + vi.stubGlobal("HTMLElement", MockHTMLElement); + + const input = new MockHTMLElement(); + input.tagName = "INPUT"; + expect( + isTextInputTarget({ target: input } as unknown as KeyboardEvent), + ).toBe(true); + + const editorHost = new MockHTMLElement(); + editorHost.closest = (selector: string) => + selector.includes("cm-editor") ? editorHost : null; + expect( + isTextInputTarget({ target: editorHost } as unknown as KeyboardEvent), + ).toBe(true); + + const div = new MockHTMLElement(); + expect( + isTextInputTarget({ target: div } as unknown as KeyboardEvent), + ).toBe(false); + + vi.unstubAllGlobals(); + }); +}); + +describe("shouldOpenShortcutsHelp", () => { + it("matches plain question mark without modifiers", () => { + expect( + shouldOpenShortcutsHelp({ + key: "?", + shiftKey: true, + ctrlKey: false, + metaKey: false, + altKey: false, + }), + ).toBe(true); + expect( + shouldOpenShortcutsHelp({ + key: "k", + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + }), + ).toBe(false); + expect( + shouldOpenShortcutsHelp({ + key: "?", + shiftKey: true, + ctrlKey: true, + metaKey: false, + altKey: false, + }), + ).toBe(false); + }); +}); + +describe("plain question mark shortcut", () => { + it("does not match mod+/ binding without a modifier", () => { + const e = { + key: "?", + ctrlKey: false, + metaKey: false, + shiftKey: true, + altKey: false, + } as KeyboardEvent; + expect(matchBoundAction(e, DEFAULT_KEYBINDINGS)).toBeNull(); + }); +}); diff --git a/ui/src/lib/kiwiKeybindings.ts b/ui/src/lib/kiwiKeybindings.ts index 6c6bc94a..1d848e1e 100644 --- a/ui/src/lib/kiwiKeybindings.ts +++ b/ui/src/lib/kiwiKeybindings.ts @@ -129,7 +129,7 @@ export function eventMatchesChord(e: KeyboardEvent, chord: string): boolean { return eventKey === parsed.key; } -export function formatChordDisplay(chord: string): string { +export function formatChordSegments(chord: string): string[] { const isMac = typeof navigator !== "undefined" && navigator.platform.includes("Mac"); const parsed = parseChord(chord); const parts: string[] = []; @@ -141,12 +141,40 @@ export function formatChordDisplay(chord: string): string { if (parsed.key === "/") keyLabel = "/"; if (parsed.key === "?") keyLabel = "?"; if (parsed.key === "escape") keyLabel = "Esc"; + parts.push(keyLabel); + return parts; +} - if (isMac && parsed.mod && !parsed.shift && !parsed.alt) { - return `${parts[0]}${keyLabel}`; +export function formatChordDisplay(chord: string): string { + const segments = formatChordSegments(chord); + const isMac = typeof navigator !== "undefined" && navigator.platform.includes("Mac"); + const parsed = parseChord(chord); + + if (isMac && parsed.mod && !parsed.shift && !parsed.alt && segments.length === 2) { + return `${segments[0]}${segments[1]}`; } - if (parts.length === 0) return keyLabel; - return `${parts.join("+")}+${keyLabel}`; + if (segments.length <= 1) return segments[0] ?? ""; + return segments.join("+"); +} + +/** True when the event target is an editable field (skip global shortcuts). */ +export function isTextInputTarget(e: KeyboardEvent): boolean { + const target = e.target; + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + if (target.isContentEditable) return true; + return Boolean( + target.closest(".cm-editor, [contenteditable='true'], [role='textbox'], [cmdk-input], [cmdk-input-wrapper]"), + ); +} + +/** Plain `?` (no ctrl/meta/alt) opens the shortcuts overlay outside editable targets. */ +export function shouldOpenShortcutsHelp( + e: Pick, +): boolean { + if (e.ctrlKey || e.metaKey || e.altKey) return false; + return e.key === "?" || (e.key === "/" && e.shiftKey); } export function mergeKeybindings(config: KeybindingsConfig | null | undefined): Record { @@ -200,6 +228,30 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ }, ]; +const ACTION_LABELS: Record = Object.fromEntries( + SHORTCUT_SECTIONS.flatMap((section) => + section.items.map((item) => [item.action, item.label] as const), + ), +) as Record; + +export function actionLabel(action: KeybindingAction): string { + return ACTION_LABELS[action] ?? action.replace(/_/g, " "); +} + +export function getCustomShortcutItems( + bindings: Record, + defaults: Partial>, +): ShortcutSection["items"] { + const items: ShortcutSection["items"] = []; + for (const action of Object.keys(DEFAULT_KEYBINDINGS) as KeybindingAction[]) { + const chord = bindings[action]; + const def = defaults[action] ?? DEFAULT_KEYBINDINGS[action]; + if (!chord || normalizeChord(def) === normalizeChord(chord)) continue; + items.push({ action, label: actionLabel(action) }); + } + return items.sort((a, b) => a.label.localeCompare(b.label)); +} + export function buildChordIndex(bindings: Record): Map { const index = new Map(); for (const [action, chord] of Object.entries(bindings) as [KeybindingAction, string][]) { diff --git a/ui/src/lib/paths.ts b/ui/src/lib/paths.ts index f8d22219..8367fc72 100644 --- a/ui/src/lib/paths.ts +++ b/ui/src/lib/paths.ts @@ -17,6 +17,17 @@ export function isExcalidrawFile(p: string): boolean { return p.toLowerCase().endsWith(".excalidraw.md"); } +export function normalizePath(p: string): string { + const parts = p.split("/"); + const out: string[] = []; + for (const seg of parts) { + if (seg === "." || seg === "") continue; + if (seg === ".." && out.length > 0) out.pop(); + else if (seg !== "..") out.push(seg); + } + return out.join("/"); +} + export function dirOf(p: string): string { const clean = stripTrailingSlash(p); const idx = clean.lastIndexOf("/"); diff --git a/ui/src/lib/treeOrderPersistence.test.ts b/ui/src/lib/treeOrderPersistence.test.ts deleted file mode 100644 index 36722b80..00000000 --- a/ui/src/lib/treeOrderPersistence.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { persistSiblingOrder } from "./treeOrderPersistence"; -import type { FlatNode } from "./treeTransform"; - -const sibling = (id: string, order?: number): FlatNode => ({ - id, - name: id.split("/").pop() ?? id, - isDir: false, - order, -}); - -describe("tree order persistence", () => { - it("includes the failing markdown path when order frontmatter patch fails", async () => { - const entries = [sibling("00 Inbox/a.md", 2), sibling("00 Inbox/broken.md", 1)]; - const api = { - patchFrontmatter: async (path: string) => { - if (path === "00 Inbox/broken.md") throw new Error("frontmatter-yaml-invalid"); - return { path, etag: "ok" }; - }, - patchTreeOrder: async () => ({ updated: 0 }), - }; - - await expect(persistSiblingOrder(entries, api)).rejects.toThrow( - "00 Inbox/broken.md", - ); - }); -}); diff --git a/ui/src/lib/treeOrderPersistence.ts b/ui/src/lib/treeOrderPersistence.ts deleted file mode 100644 index 00f52af2..00000000 --- a/ui/src/lib/treeOrderPersistence.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { apiErrorMessage } from "./api"; -import { isMarkdown, stripTrailingSlash } from "./paths"; -import type { FlatNode } from "./treeTransform"; - -type OrderApi = { - patchFrontmatter(path: string, fields: Record): Promise; - patchTreeOrder(orders: Record): Promise; -}; - -type OrderUpdatePlan = { - directoryOrders: Record; - markdownOrders: { path: string; order: number }[]; -}; - -/** - * Reports whether a tree row can own a persisted sibling order. - * - * @param entry - Flattened tree row produced from the server tree. - * @returns True when the row is a real directory or markdown page. - */ -const isOrderableEntry = (entry: FlatNode): boolean => { - if (entry.virtualDir) { - return false; - } - if (entry.isDir) { - return true; - } - return isMarkdown(entry.id); -}; - -/** - * Builds the immutable order update plan for a sibling list. - * - * @param entries - Sibling rows after the optimistic drag-and-drop move. - * @returns Directory metadata updates and markdown frontmatter updates. - */ -const planSiblingOrderUpdates = (entries: FlatNode[]): OrderUpdatePlan => { - return entries.filter(isOrderableEntry).reduce((plan, entry, index) => { - const order = index + 1; - if (entry.order === order) { - return plan; - } - - const cleanPath = stripTrailingSlash(entry.id); - if (entry.isDir) { - return { - directoryOrders: { ...plan.directoryOrders, [cleanPath]: order }, - markdownOrders: plan.markdownOrders, - }; - } - - return { - directoryOrders: plan.directoryOrders, - markdownOrders: [...plan.markdownOrders, { path: cleanPath, order }], - }; - }, { directoryOrders: {}, markdownOrders: [] }); -}; - -/** - * Persists a single markdown page's order into frontmatter and includes the - * path in the thrown error so drag failures identify the blocked file. - * - * @param orderApi - API facade used by the tree container. - * @param path - Markdown page path without a trailing slash. - * @param order - New one-based sibling order. - * @returns Nothing. - */ -const patchMarkdownOrder = async (orderApi: OrderApi, path: string, order: number): Promise => { - try { - await orderApi.patchFrontmatter(path, { order }); - return; - } catch (error) { - throw new Error(`Failed to update order for ${path}: ${apiErrorMessage(error)}`); - } -}; - -/** - * Persists sibling order after an optimistic tree move. - * - * Markdown rows are written through frontmatter while directory rows share a - * tree-order sidecar because folders cannot store markdown frontmatter. - * - * @param entries - Sibling rows in their final visual order. - * @param orderApi - API facade used to write frontmatter and tree metadata. - * @returns Nothing. - */ -export const persistSiblingOrder = async (entries: FlatNode[], orderApi: OrderApi): Promise => { - const plan = planSiblingOrderUpdates(entries); - const markdownUpdates = plan.markdownOrders.map(({ path, order }) => patchMarkdownOrder(orderApi, path, order)); - const directoryUpdates: Promise[] = []; - if (Object.keys(plan.directoryOrders).length > 0) { - directoryUpdates.push(orderApi.patchTreeOrder(plan.directoryOrders)); - } - - await Promise.all([...markdownUpdates, ...directoryUpdates]); -}; diff --git a/ui/src/lib/treeReorder.test.ts b/ui/src/lib/treeReorder.test.ts index 6746e6ec..866391c0 100644 --- a/ui/src/lib/treeReorder.test.ts +++ b/ui/src/lib/treeReorder.test.ts @@ -7,20 +7,20 @@ const root = (): TreeEntry => ({ name: "/", isDir: true, children: [ - { path: "a.md", name: "a.md", isDir: false, order: 1 }, - { path: "b.md", name: "b.md", isDir: false, order: 2 }, - { path: "c.md", name: "c.md", isDir: false, order: 3 }, + { path: "a.md", name: "a.md", isDir: false }, + { path: "b.md", name: "b.md", isDir: false }, + { path: "c.md", name: "c.md", isDir: false }, { path: "folder/", name: "folder", isDir: true, - children: [{ path: "folder/d.md", name: "d.md", isDir: false, order: 1 }], + children: [{ path: "folder/d.md", name: "d.md", isDir: false }], }, ], }); describe("applyOptimisticTreeMove", () => { - it("reorders siblings immediately and updates markdown order fields", () => { + it("reorders siblings immediately", () => { const next = applyOptimisticTreeMove(root(), { dragIds: ["c.md"], parentId: null, @@ -28,7 +28,6 @@ describe("applyOptimisticTreeMove", () => { }); expect(next.children?.map((entry) => entry.path)).toEqual(["c.md", "a.md", "b.md", "folder/"]); - expect(next.children?.slice(0, 3).map((entry) => entry.order)).toEqual([1, 2, 3]); }); it("moves a markdown page into another folder immediately", () => { @@ -41,7 +40,6 @@ describe("applyOptimisticTreeMove", () => { expect(next.children?.map((entry) => entry.path)).toEqual(["a.md", "c.md", "folder/"]); const folder = next.children?.find((entry) => entry.path === "folder/"); expect(folder?.children?.map((entry) => entry.path)).toEqual(["folder/d.md", "folder/b.md"]); - expect(folder?.children?.map((entry) => entry.order)).toEqual([1, 2]); }); it("moves a folder and retargets descendant paths immediately", () => { diff --git a/ui/src/lib/treeReorder.ts b/ui/src/lib/treeReorder.ts index 810d557e..d0a02a18 100644 --- a/ui/src/lib/treeReorder.ts +++ b/ui/src/lib/treeReorder.ts @@ -1,5 +1,5 @@ import type { TreeEntry } from "./api"; -import { isMarkdown, stripTrailingSlash } from "./paths"; +import { stripTrailingSlash } from "./paths"; export type OptimisticTreeMoveArgs = { dragIds: string[]; @@ -128,34 +128,6 @@ const removeEntry = (children: TreeEntry[], id: string): RemoveResult => { }, { children: [], removed: null }); }; -/** - * Reports whether a row should receive a visual sibling order number. - * - * @param child - Tree row in the destination sibling list. - * @returns True for directories and markdown files. - */ -const isOrderableSibling = (child: TreeEntry): boolean => { - if (child.isDir) { - return true; - } - return isMarkdown(child.path); -}; - -/** - * Reassigns one-based order values to directories and markdown files only. - * - * @param children - Destination sibling list after insertion. - * @returns A copied sibling list with updated order fields where applicable. - */ -const renumberOrderableSiblings = (children: TreeEntry[]): TreeEntry[] => { - return children.reduce<{ rows: TreeEntry[]; order: number }>((state, child) => { - if (!isOrderableSibling(child)) { - return { rows: [...state.rows, child], order: state.order }; - } - return { rows: [...state.rows, { ...child, order: state.order }], order: state.order + 1 }; - }, { rows: [], order: 1 }).rows; -}; - /** * Inserts a row into an immutable sibling list at a clamped index. * @@ -163,16 +135,16 @@ const renumberOrderableSiblings = (children: TreeEntry[]): TreeEntry[] => { * @param parentId - Destination folder id, or null for the root. * @param index - Requested insertion index from the tree widget. * @param entry - Removed row to insert. - * @returns New sibling list with recalculated order values. + * @returns New sibling list with the moved row at the requested position. */ const insertAtIndex = (children: TreeEntry[], parentId: string | null, index: number, entry: TreeEntry): TreeEntry[] => { const safeIndex = Math.max(0, Math.min(index, children.length)); const moved = retargetMovedEntry(entry, parentId); - return renumberOrderableSiblings([ + return [ ...children.slice(0, safeIndex), moved, ...children.slice(safeIndex), - ]); + ]; }; /** diff --git a/ui/src/lib/treeTransform.test.ts b/ui/src/lib/treeTransform.test.ts index aa69dc8e..60d549db 100644 --- a/ui/src/lib/treeTransform.test.ts +++ b/ui/src/lib/treeTransform.test.ts @@ -3,23 +3,25 @@ import { buildFlatTree } from "./treeTransform"; import type { TreeEntry } from "./api"; describe("treeTransform ordering", () => { - it("sorts ordered siblings before unordered siblings, then by name", () => { + it("sorts siblings by natural sort order", () => { const root: TreeEntry = { path: "", name: "/", isDir: true, children: [ { path: "zeta.md", name: "zeta.md", isDir: false }, - { path: "beta.md", name: "beta.md", isDir: false, order: 2 }, - { path: "alpha.md", name: "alpha.md", isDir: false, order: 1 }, - { path: "folder/", name: "folder", isDir: true, children: [] }, + { path: "10-graphs/", name: "10-graphs", isDir: true, children: [] }, + { path: "2-arrays/", name: "2-arrays", isDir: true, children: [] }, + { path: "alpha.md", name: "alpha.md", isDir: false }, + { path: "1-math/", name: "1-math", isDir: true, children: [] }, ], }; expect(buildFlatTree(root, { enableFileNesting: false }).map((n) => n.id)).toEqual([ + "1-math", + "2-arrays", + "10-graphs", "alpha.md", - "beta.md", - "folder", "zeta.md", ]); }); diff --git a/ui/src/lib/treeTransform.ts b/ui/src/lib/treeTransform.ts index cd2c9f6d..2f404c4e 100644 --- a/ui/src/lib/treeTransform.ts +++ b/ui/src/lib/treeTransform.ts @@ -15,8 +15,6 @@ export type FlatNode = { isNested?: boolean; /** Matched by exclude pattern — dimmed in UI */ excluded?: boolean; - /** Persisted sibling order from frontmatter or tree sidecar metadata. */ - order?: number; /** Safe lint summary shown when markdown frontmatter blocks ordering writes. */ frontmatterError?: string; children?: FlatNode[]; @@ -150,21 +148,11 @@ const compareEntries = (a: TreeEntry, b: TreeEntry, mode: TreeSortMode): number return kiwiDelta; } - if (a.order != null && b.order != null && a.order !== b.order) { - return a.order - b.order; - } - if (a.order != null && b.order == null) { - return -1; - } - if (a.order == null && b.order != null) { - return 1; - } - const typeDelta = compareEntryType(a, b, mode); if (typeDelta !== 0) { return typeDelta; } - return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); }; /** @@ -182,7 +170,6 @@ const fileToFlat = (entry: TreeEntry, opts: TransformOpts, extra: Partial { } return -1; } - if (a.order != null && b.order != null && a.order !== b.order) { - return a.order - b.order; - } - if (a.order != null && b.order == null) { - return -1; - } - if (a.order == null && b.order != null) { - return 1; - } if (a.isDir !== b.isDir) { if (a.isDir) { return -1; } return 1; } - return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); }; /** @@ -284,7 +262,6 @@ const markdownToNestedFlat = (markdown: TreeEntry, files: TreeEntry[], opts: Tra isDir: true, virtualDir: true, excluded: isTreePathExcluded(markdown.path, opts.excludePatterns), - order: markdown.order, frontmatterError: markdown.frontmatterError, children: nestedFiles.map((file) => fileToFlat(file, opts, { isNested: true })), }; @@ -379,7 +356,6 @@ const dirToFlat = (entry: TreeEntry, opts: TransformOpts): FlatNode => { name: entry.name, isDir: true, excluded: isTreePathExcluded(path, opts.excludePatterns), - order: entry.order, }; } @@ -388,7 +364,6 @@ const dirToFlat = (entry: TreeEntry, opts: TransformOpts): FlatNode => { name: entry.name, isDir: true, excluded: isTreePathExcluded(path, opts.excludePatterns), - order: entry.order, children: transformChildren(entry.children, opts), }; }; diff --git a/ui/src/widgets/AnnotationBar.tsx b/ui/src/widgets/AnnotationBar.tsx index 2c0beec9..df29e961 100644 --- a/ui/src/widgets/AnnotationBar.tsx +++ b/ui/src/widgets/AnnotationBar.tsx @@ -1,5 +1,7 @@ +import type { ReactNode } from "react"; + export interface AnnotationBarProps { - /** The current step's text explanation. Supports simple text only. */ + /** Step explanation. Supports inline markdown: **bold**, *italic*, `code`. */ text: string; /** Optional label prefix (e.g. step number). */ label?: string; @@ -22,6 +24,45 @@ const VARIANT_STYLES = { }, }; +/** Parse inline markdown (**bold**, *italic*, `code`) into React elements. */ +function parseInlineMarkdown(text: string): (string | ReactNode)[] { + const parts: (string | ReactNode)[] = []; + const pattern = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + let key = 0; + + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + if (match[2]) { + parts.push({match[2]}); + } else if (match[3]) { + parts.push({match[3]}); + } else if (match[4]) { + parts.push( + + {match[4]} + + ); + } + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 0 ? parts : [text]; +} + export function AnnotationBar({ text, label, variant = "info" }: AnnotationBarProps) { const style = VARIANT_STYLES[variant]; @@ -48,7 +89,7 @@ export function AnnotationBar({ text, label, variant = "info" }: AnnotationBarPr {label} )} - {text} + {parseInlineMarkdown(text)} ); } diff --git a/ui/src/widgets/ArrayView.tsx b/ui/src/widgets/ArrayView.tsx index 7d7c0e0b..ebf6f915 100644 --- a/ui/src/widgets/ArrayView.tsx +++ b/ui/src/widgets/ArrayView.tsx @@ -14,6 +14,8 @@ export interface ArrayPointer { export interface ArrayViewProps { /** The array values to display. */ values: (string | number)[]; + /** Optional sublabel per cell (shown below the value, inside the cell). */ + sublabels?: (string | number | null | undefined)[]; /** Index of the currently active cell (highlighted). */ activeIndex?: number; /** Set of indices that should be highlighted as "secondary" (e.g. part of a streak). */ @@ -76,6 +78,7 @@ function getCellStyle( export function ArrayView({ values, + sublabels, activeIndex, highlightIndices, dimIndices, @@ -97,6 +100,12 @@ export function ArrayView({ const style = getCellStyle(i, activeIndex, highlightIndices, dimIndices, activeColor, highlightColor); const ptrs = pointersByIndex.get(i); + const sub = sublabels?.[i]; + const hasSub = sub != null && sub !== ""; + const mainFontSize = hasSub + ? (cellSize > 40 ? "0.85rem" : "0.75rem") + : (cellSize > 40 ? "1rem" : "0.85rem"); + return (
{/* Pointer labels above */} @@ -117,15 +126,27 @@ export function ArrayView({ color: style.color, opacity: style.opacity ?? 1, display: "flex", + flexDirection: "column", alignItems: "center", justifyContent: "center", fontWeight: 700, - fontSize: cellSize > 40 ? "1rem" : "0.85rem", + fontSize: mainFontSize, transition: "all 0.2s ease", fontVariantNumeric: "tabular-nums", + gap: 0, }} > - {val} + {val} + {hasSub && ( + + {sub} + + )}
{/* Index label below */} diff --git a/ui/src/widgets/GraphView.tsx b/ui/src/widgets/GraphView.tsx index 682ff83d..3dd5ae23 100644 --- a/ui/src/widgets/GraphView.tsx +++ b/ui/src/widgets/GraphView.tsx @@ -147,7 +147,7 @@ export function GraphView({ markerEnd={marker} style={{ transition: "all 0.25s ease" }} /> - {e.weight !== undefined && ( + {(e.weight !== undefined || e.label) && ( - {e.weight} + {e.weight !== undefined ? e.weight : e.label} )} diff --git a/ui/src/widgets/MatrixView.tsx b/ui/src/widgets/MatrixView.tsx index 6e75bbef..9071e723 100644 --- a/ui/src/widgets/MatrixView.tsx +++ b/ui/src/widgets/MatrixView.tsx @@ -1,5 +1,5 @@ export interface MatrixViewProps { - /** 2D array of cell values. */ + /** 2D array of cell values. Rows can have different lengths (ragged/triangular). */ values: (string | number)[][]; /** [row, col] of the active cell. */ activeCell?: [number, number]; @@ -13,6 +13,11 @@ export interface MatrixViewProps { colPointers?: { col: number; label: string; color?: string }[]; /** Whether to show row/col indices. Default true. */ showIndices?: boolean; + /** Ragged row mode — only render actual cells per row (no padding to max width). + * "start" = left-aligned (staircase), "center" = centered (pyramid). Default false. */ + centerRows?: boolean | "start" | "center"; + /** Use circular cells instead of squares (for coin/token grids). Default false. */ + roundCells?: boolean; activeColor?: string; highlightColor?: string; cellSize?: number; @@ -35,6 +40,8 @@ export function MatrixView({ rowPointers = [], colPointers = [], showIndices = true, + centerRows = false, + roundCells = false, activeColor = DEFAULTS.activeColor, highlightColor = DEFAULTS.highlightColor, cellSize = DEFAULTS.cellSize, @@ -48,6 +55,12 @@ export function MatrixView({ } const cols = Math.max(...values.map((r) => r.length)); + const raggedAlign = centerRows === true || centerRows === "center" + ? "center" + : centerRows === "start" + ? "flex-start" + : undefined; + const isRagged = !!centerRows; const rowPtrMap = new Map(); for (const p of rowPointers) { @@ -65,8 +78,8 @@ export function MatrixView({ return (
- {/* Column pointer row */} - {colPointers.length > 0 && ( + {/* Column pointer row (hidden for ragged layouts) */} + {colPointers.length > 0 && !isRagged && (
{Array.from({ length: cols }, (_, c) => { const ptrs = colPtrMap.get(c); @@ -81,8 +94,8 @@ export function MatrixView({
)} - {/* Column index row */} - {showIndices && ( + {/* Column index row (hidden for ragged layouts) */} + {showIndices && !isRagged && (
{Array.from({ length: cols }, (_, c) => (
{ const rptrs = rowPtrMap.get(r); + const rowLen = isRagged ? row.length : cols; return ( -
+
{/* Row index */} - {showIndices && ( + {showIndices && !isRagged && (
{ + {Array.from({ length: rowLen }, (_, c) => { const key = `${r},${c}`; const isActive = activeCell && activeCell[0] === r && activeCell[1] === c; const isHighlight = highlightCells?.has(key) ?? false; @@ -151,6 +165,7 @@ export function MatrixView({ width: cellSize, height: cellSize, border: `1.5px solid ${border}`, + borderRadius: roundCells ? "50%" : 0, background: bg, color, opacity, @@ -161,7 +176,7 @@ export function MatrixView({ fontWeight: 700, fontVariantNumeric: "tabular-nums", transition: "all 0.2s ease", - margin: -0.5, + margin: roundCells ? 1 : -0.5, }} > {val} diff --git a/ui/src/widgets/PageTracker.tsx b/ui/src/widgets/PageTracker.tsx index 0207142c..1ab8d03b 100644 --- a/ui/src/widgets/PageTracker.tsx +++ b/ui/src/widgets/PageTracker.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { api, type TreeEntry } from "@kw/lib/api"; import { titleize } from "@kw/lib/paths"; +import { Badge } from "@kw/components/ui/badge"; import { CheckCircle2, Circle, ChevronDown, ChevronRight, Calendar as CalendarIcon } from "lucide-react"; type ProgressEntry = { @@ -10,10 +11,17 @@ type ProgressEntry = { type ProgressState = Record; +type PageMeta = { + title?: string; + difficulty?: string; +}; + type PageItem = { path: string; name: string; title: string; + subsection?: string; + meta?: PageMeta; }; type FolderGroup = { @@ -22,23 +30,57 @@ type FolderGroup = { pages: PageItem[]; }; +function naturalCompare(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); +} + +function isProblemFile(entry: TreeEntry): boolean { + return !entry.isDir && entry.name.endsWith(".md") && !entry.name.startsWith("_"); +} + +/** Recursively collect problem pages under a chapter folder (includes subfolders). */ +function collectChapterPages(chapter: TreeEntry): PageItem[] { + const chapterPrefix = chapter.path.replace(/\/$/, ""); + const pages: PageItem[] = []; + + function walk(node: TreeEntry) { + for (const child of node.children ?? []) { + if (child.isDir) { + walk(child); + continue; + } + if (!isProblemFile(child)) continue; + + const rel = child.path.startsWith(chapterPrefix + "/") + ? child.path.slice(chapterPrefix.length + 1) + : child.path; + const segments = rel.split("/"); + const subsection = segments.length > 1 ? titleize(segments[segments.length - 2]) : undefined; + + pages.push({ + path: child.path, + name: child.name, + title: titleize(child.name.replace(/\.md$/, "")), + subsection, + }); + } + } + + walk(chapter); + pages.sort((a, b) => naturalCompare(a.path, b.path)); + return pages; +} + function deriveGroups(tree: TreeEntry | null): FolderGroup[] { if (!tree?.children) return []; const groups: FolderGroup[] = []; const sorted = [...tree.children] .filter((c) => c.isDir && /^\d+-/.test(c.name)) - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + .sort((a, b) => naturalCompare(a.name, b.name)); for (const dir of sorted) { - const pages: PageItem[] = (dir.children ?? []) - .filter((f) => !f.isDir && f.name.endsWith(".md") && !f.name.startsWith("_")) - .map((f) => ({ - path: f.path, - name: f.name, - title: titleize(f.name.replace(/\.md$/, "")), - })); - + const pages = collectChapterPages(dir); if (pages.length > 0) { groups.push({ folder: dir.path, @@ -51,6 +93,33 @@ function deriveGroups(tree: TreeEntry | null): FolderGroup[] { return groups; } +function parsePageMeta(fm: Record): PageMeta { + const meta: PageMeta = {}; + if (typeof fm.title === "string") meta.title = fm.title; + if (typeof fm.difficulty === "string") meta.difficulty = fm.difficulty; + return meta; +} + +function difficultyClass(d: string): string { + const v = d.toLowerCase(); + if (v === "easy") return "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"; + if (v === "medium") return "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"; + if (v === "hard") return "border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-400"; + return ""; +} + +function PageTags({ meta }: { meta?: PageMeta }) { + if (!meta?.difficulty) return null; + + return ( +
+ + {meta.difficulty} + +
+ ); +} + type Props = { onNavigate?: (path: string) => void; stateName?: string; @@ -59,6 +128,7 @@ type Props = { export function PageTracker({ onNavigate, stateName = "progress" }: Props) { const [tree, setTree] = useState(null); const [progress, setProgress] = useState({}); + const [metaByPath, setMetaByPath] = useState>({}); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const [loading, setLoading] = useState(true); @@ -67,16 +137,32 @@ export function PageTracker({ onNavigate, stateName = "progress" }: Props) { Promise.all([ api.tree(), api.getLocalState(stateName), - ]).then(([t, p]) => { + api.meta({ where: [{ field: "$.difficulty", op: "!=", value: "" }], limit: 5000 }), + ]).then(([t, p, metaRes]) => { if (cancelled) return; setTree(t); setProgress(p ?? {}); + const map: Record = {}; + for (const row of metaRes.results) { + map[row.path] = parsePageMeta(row.frontmatter); + } + setMetaByPath(map); setLoading(false); }); return () => { cancelled = true; }; }, [stateName]); - const groups = useMemo(() => deriveGroups(tree), [tree]); + const groups = useMemo(() => { + const base = deriveGroups(tree); + return base.map((g) => ({ + ...g, + pages: g.pages.map((p) => ({ + ...p, + title: metaByPath[p.path]?.title ?? p.title, + meta: metaByPath[p.path], + })), + })); + }, [tree, metaByPath]); const toggleDone = useCallback((pagePath: string) => { setProgress((prev) => { @@ -173,13 +259,20 @@ export function PageTracker({ onNavigate, stateName = "progress" }: Props) { {!isCollapsed && (
- {group.pages.map((page) => { + {group.pages.map((page, index) => { const entry = progress[page.path]; const isDone = entry?.done ?? false; + const prev = index > 0 ? group.pages[index - 1] : null; + const showSubsection = page.subsection && page.subsection !== prev?.subsection; return ( +
+ {showSubsection && ( +
+ {page.subsection} +
+ )}
+ {entry?.doneAt && ( @@ -211,6 +305,7 @@ export function PageTracker({ onNavigate, stateName = "progress" }: Props) { )}
+
); })}