Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.19.40"
".": "0.19.41"
}
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
31 changes: 31 additions & 0 deletions episodes/agents/cursor-issue-428/2026-06-30-hands-on-delivery.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 0 additions & 1 deletion internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
116 changes: 45 additions & 71 deletions internal/api/handlers_file.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package api

import (
"context"
"encoding/json"
"errors"
"fmt"
Expand All @@ -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"
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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())
Expand All @@ -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
Expand Down Expand Up @@ -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/<filename> 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/<name>.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/<name>.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")
Expand All @@ -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)
Expand Down
25 changes: 11 additions & 14 deletions internal/api/handlers_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,30 +367,27 @@ 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())
}
var tree treeEntry
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)
}
}
Loading
Loading