From 36996366cf83c0b34709bc8b1698ea4e4be27db4 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 22 Feb 2026 17:36:59 +0100 Subject: [PATCH 1/7] design: vault-from-github wireframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four frames covering the GitHub vault modal flow: 1. Modal empty state — initial view with Clone/Create tabs 2. Clone repo list — browsing user repos with search 3. Create new repo — name input, visibility toggle, local path 4. Clone in progress — spinner, progress bar, status text Co-Authored-By: Claude Opus 4.6 --- design/vault-from-github.pen | 1269 ++++++++++++++++++++++++++++++++++ 1 file changed, 1269 insertions(+) create mode 100644 design/vault-from-github.pen diff --git a/design/vault-from-github.pen b/design/vault-from-github.pen new file mode 100644 index 00000000..c3d9ea70 --- /dev/null +++ b/design/vault-from-github.pen @@ -0,0 +1,1269 @@ +{ + "version": "2.8", + "children": [ + { + "type": "frame", + "id": "ghv01", + "x": 68, + "y": 0, + "name": "GitHub Vault — Modal empty state", + "clip": true, + "width": 560, + "height": 480, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv01-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv01-title", + "name": "title", + "content": "Connect GitHub Repo", + "fontSize": 16, + "fontWeight": 600, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv01-close", + "name": "closeBtn", + "width": 24, + "height": 24, + "cornerRadius": 4, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ghv01-x", + "content": "X", + "fontSize": 14, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv01-tabs", + "name": "TabBar", + "width": "fill_container", + "height": 44, + "fill": "$--background", + "padding": [0, 24], + "gap": 0, + "alignItems": "end", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv01-tab-clone", + "name": "Tab: Clone Existing", + "padding": [8, 16], + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "bottom": 2 }, + "fill": "$--accent-blue" + }, + "children": [ + { + "type": "text", + "id": "ghv01-tab-clone-lbl", + "content": "Clone Existing", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv01-tab-create", + "name": "Tab: Create New", + "padding": [8, 16], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv01-tab-create-lbl", + "content": "Create New", + "fontSize": 13, + "fontWeight": 400, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv01-body", + "name": "Body: Empty State", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [32, 24], + "gap": 16, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "ghv01-icon", + "name": "githubIcon", + "width": 48, + "height": 48, + "cornerRadius": 24, + "fill": "$--accent", + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ghv01-gh", + "content": "GH", + "fontSize": 20, + "fontWeight": 600, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "text", + "id": "ghv01-heading", + "content": "Clone an existing GitHub repository", + "fontSize": 15, + "fontWeight": 600, + "fill": "$--foreground", + "textAlign": "center" + }, + { + "type": "text", + "id": "ghv01-desc", + "content": "Search your repos or paste a URL to clone a GitHub repo as a vault.", + "fontSize": 13, + "fill": "$--muted-foreground", + "textAlign": "center", + "width": 360 + }, + { + "type": "frame", + "id": "ghv01-search", + "name": "SearchInput", + "width": 400, + "height": 40, + "cornerRadius": 8, + "fill": "$--input", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [0, 12], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv01-placeholder", + "content": "Search repos or paste URL...", + "fontSize": 13, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv01-footer", + "name": "Footer", + "width": "fill_container", + "height": 56, + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv01-hint", + "content": "Connected as @lucaong", + "fontSize": 11, + "fill": "$--muted-foreground" + }, + { + "type": "frame", + "id": "ghv01-btns", + "name": "buttons", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ghv01-cancel", + "name": "cancelBtn", + "padding": [6, 16], + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv01-cancel-lbl", + "content": "Cancel", + "fontSize": 13, + "fill": "$--foreground" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv02", + "x": 700, + "y": 0, + "name": "GitHub Vault — Clone repo list", + "clip": true, + "width": 560, + "height": 540, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv02-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv02-title", + "content": "Connect GitHub Repo", + "fontSize": 16, + "fontWeight": 600, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv02-close", + "name": "closeBtn", + "width": 24, + "height": 24, + "cornerRadius": 4, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ghv02-x", + "content": "X", + "fontSize": 14, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv02-tabs", + "name": "TabBar", + "width": "fill_container", + "height": 44, + "fill": "$--background", + "padding": [0, 24], + "gap": 0, + "alignItems": "end", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv02-tab-clone", + "name": "Tab: Clone Existing (active)", + "padding": [8, 16], + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "bottom": 2 }, + "fill": "$--accent-blue" + }, + "children": [ + { + "type": "text", + "id": "ghv02-tab-clone-lbl", + "content": "Clone Existing", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv02-tab-create", + "name": "Tab: Create New", + "padding": [8, 16], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-tab-create-lbl", + "content": "Create New", + "fontSize": 13, + "fontWeight": 400, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv02-body", + "name": "Body: Repo List", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [16, 24], + "gap": 12, + "children": [ + { + "type": "frame", + "id": "ghv02-search", + "name": "SearchInput (filled)", + "width": "fill_container", + "height": 40, + "cornerRadius": 8, + "fill": "$--input", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--ring" + }, + "padding": [0, 12], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-search-val", + "content": "laputa", + "fontSize": 13, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv02-list", + "name": "RepoList", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "ghv02-repo1", + "name": "RepoItem: selected", + "width": "fill_container", + "height": 56, + "cornerRadius": 6, + "fill": "$--accent", + "padding": [8, 12], + "layout": "vertical", + "gap": 4, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "ghv02-repo1-row", + "name": "topRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-repo1-name", + "content": "lucaong/laputa-vault", + "fontSize": 13, + "fontWeight": 600, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv02-repo1-badge", + "name": "privateBadge", + "padding": [2, 6], + "cornerRadius": 4, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv02-repo1-priv", + "content": "Private", + "fontSize": 10, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "text", + "id": "ghv02-repo1-desc", + "content": "Personal knowledge vault — markdown + YAML frontmatter", + "fontSize": 12, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv02-repo2", + "name": "RepoItem: unselected", + "width": "fill_container", + "height": 56, + "cornerRadius": 6, + "padding": [8, 12], + "layout": "vertical", + "gap": 4, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "ghv02-repo2-row", + "name": "topRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-repo2-name", + "content": "lucaong/laputa-app", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv02-repo2-badge", + "name": "publicBadge", + "padding": [2, 6], + "cornerRadius": 4, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv02-repo2-pub", + "content": "Public", + "fontSize": 10, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "text", + "id": "ghv02-repo2-desc", + "content": "Laputa desktop app — Tauri + React + CodeMirror 6", + "fontSize": 12, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv02-repo3", + "name": "RepoItem: unselected 2", + "width": "fill_container", + "height": 56, + "cornerRadius": 6, + "padding": [8, 12], + "layout": "vertical", + "gap": 4, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "ghv02-repo3-row", + "name": "topRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-repo3-name", + "content": "lucaong/dotfiles", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + } + ] + }, + { + "type": "text", + "id": "ghv02-repo3-desc", + "content": "My macOS dotfiles and config", + "fontSize": 12, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv02-path", + "name": "LocalPathPicker", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "ghv02-path-lbl", + "content": "Clone to:", + "fontSize": 12, + "fontWeight": 500, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv02-path-input", + "name": "pathInput", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "fill": "$--input", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [0, 10], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-path-val", + "content": "~/Vaults/laputa-vault", + "fontSize": 12, + "fill": "$--foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv02-footer", + "name": "Footer", + "width": "fill_container", + "height": 56, + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv02-hint", + "content": "3 repos found", + "fontSize": 11, + "fill": "$--muted-foreground" + }, + { + "type": "frame", + "id": "ghv02-btns", + "name": "buttons", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ghv02-cancel", + "name": "cancelBtn", + "padding": [6, 16], + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv02-cancel-lbl", + "content": "Cancel", + "fontSize": 13, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv02-clone-btn", + "name": "cloneBtn", + "padding": [6, 16], + "cornerRadius": 6, + "fill": "$--primary", + "children": [ + { + "type": "text", + "id": "ghv02-clone-lbl", + "content": "Clone & Open", + "fontSize": 13, + "fontWeight": 500, + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv03", + "x": 68, + "y": 600, + "name": "GitHub Vault — Create new repo", + "clip": true, + "width": 560, + "height": 440, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv03-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv03-title", + "content": "Connect GitHub Repo", + "fontSize": 16, + "fontWeight": 600, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv03-close", + "name": "closeBtn", + "width": 24, + "height": 24, + "cornerRadius": 4, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ghv03-x", + "content": "X", + "fontSize": 14, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv03-tabs", + "name": "TabBar", + "width": "fill_container", + "height": 44, + "fill": "$--background", + "padding": [0, 24], + "gap": 0, + "alignItems": "end", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv03-tab-clone", + "name": "Tab: Clone Existing", + "padding": [8, 16], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv03-tab-clone-lbl", + "content": "Clone Existing", + "fontSize": 13, + "fontWeight": 400, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv03-tab-create", + "name": "Tab: Create New (active)", + "padding": [8, 16], + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "bottom": 2 }, + "fill": "$--accent-blue" + }, + "children": [ + { + "type": "text", + "id": "ghv03-tab-create-lbl", + "content": "Create New", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv03-body", + "name": "Body: Create Form", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [24, 24], + "gap": 20, + "children": [ + { + "type": "frame", + "id": "ghv03-name-field", + "name": "RepoNameField", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "ghv03-name-lbl", + "content": "Repository name", + "fontSize": 12, + "fontWeight": 500, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv03-name-input", + "name": "nameInput", + "width": "fill_container", + "height": 40, + "cornerRadius": 8, + "fill": "$--input", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [0, 12], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv03-name-val", + "content": "my-vault", + "fontSize": 13, + "fill": "$--foreground" + } + ] + }, + { + "type": "text", + "id": "ghv03-name-hint", + "content": "github.com/lucaong/my-vault", + "fontSize": 11, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv03-vis-field", + "name": "VisibilityToggle", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "ghv03-vis-lbl", + "content": "Visibility", + "fontSize": 12, + "fontWeight": 500, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv03-vis-opts", + "name": "options", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ghv03-opt-priv", + "name": "PrivateOption (selected)", + "padding": [6, 12], + "cornerRadius": 6, + "fill": "$--accent", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--accent-blue" + }, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv03-opt-priv-icon", + "content": "Lock", + "fontSize": 13, + "fill": "$--accent-blue" + }, + { + "type": "text", + "id": "ghv03-opt-priv-lbl", + "content": "Private", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv03-opt-pub", + "name": "PublicOption", + "padding": [6, 12], + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv03-opt-pub-icon", + "content": "Globe", + "fontSize": 13, + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "ghv03-opt-pub-lbl", + "content": "Public", + "fontSize": 13, + "fill": "$--muted-foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv03-path-field", + "name": "LocalPathField", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "ghv03-path-lbl", + "content": "Clone to:", + "fontSize": 12, + "fontWeight": 500, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv03-path-input", + "name": "pathInput", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "fill": "$--input", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [0, 10], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv03-path-val", + "content": "~/Vaults/my-vault", + "fontSize": 12, + "fill": "$--foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv03-footer", + "name": "Footer", + "width": "fill_container", + "height": 56, + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "end", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv03-btns", + "name": "buttons", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ghv03-cancel", + "name": "cancelBtn", + "padding": [6, 16], + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv03-cancel-lbl", + "content": "Cancel", + "fontSize": 13, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv03-create-btn", + "name": "createBtn", + "padding": [6, 16], + "cornerRadius": 6, + "fill": "$--primary", + "children": [ + { + "type": "text", + "id": "ghv03-create-lbl", + "content": "Create & Clone", + "fontSize": 13, + "fontWeight": 500, + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv04", + "x": 700, + "y": 600, + "name": "GitHub Vault — Clone in progress", + "clip": true, + "width": 560, + "height": 320, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv04-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv04-title", + "content": "Cloning Repository...", + "fontSize": 16, + "fontWeight": 600, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv04-body", + "name": "Body: Progress", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [32, 24], + "gap": 20, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "ghv04-spinner", + "name": "spinner", + "width": 40, + "height": 40, + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 3, + "fill": "$--accent-blue" + } + }, + { + "type": "text", + "id": "ghv04-status", + "content": "Cloning lucaong/laputa-vault...", + "fontSize": 14, + "fontWeight": 500, + "fill": "$--foreground", + "textAlign": "center" + }, + { + "type": "text", + "id": "ghv04-sub", + "content": "Receiving objects: 67% (1,234/1,842)", + "fontSize": 12, + "fill": "$--muted-foreground", + "textAlign": "center" + }, + { + "type": "frame", + "id": "ghv04-bar-bg", + "name": "progressBarBg", + "width": 360, + "height": 4, + "cornerRadius": 2, + "fill": "$--accent", + "children": [ + { + "type": "frame", + "id": "ghv04-bar-fill", + "name": "progressBarFill", + "width": 241, + "height": 4, + "cornerRadius": 2, + "fill": "$--accent-blue" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv04-footer", + "name": "Footer", + "width": "fill_container", + "height": 56, + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "end", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv04-cancel", + "name": "cancelBtn", + "padding": [6, 16], + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv04-cancel-lbl", + "content": "Cancel", + "fontSize": 13, + "fill": "$--foreground" + } + ] + } + ] + } + ] + } + ], + "variables": { + "--accent": { + "type": "color", + "value": [{ "value": "#EBEBEA" }, { "value": "#252545", "theme": { "Mode": "Dark" } }] + }, + "--accent-blue": { + "type": "color", + "value": [{ "value": "#2383E2" }, { "value": "#4a9eff", "theme": { "Mode": "Dark" } }] + }, + "--background": { + "type": "color", + "value": [{ "value": "#FFFFFF" }, { "value": "#0f0f1a", "theme": { "Mode": "Dark" } }] + }, + "--border": { + "type": "color", + "value": [{ "value": "#E9E9E7" }, { "value": "#2a2a4a", "theme": { "Mode": "Dark" } }] + }, + "--foreground": { + "type": "color", + "value": [{ "value": "#37352F" }, { "value": "#e0e0e0", "theme": { "Mode": "Dark" } }] + }, + "--input": { + "type": "color", + "value": [{ "value": "#F7F6F3" }, { "value": "#1a1a30", "theme": { "Mode": "Dark" } }] + }, + "--muted-foreground": { + "type": "color", + "value": [{ "value": "#787774" }, { "value": "#9090a0", "theme": { "Mode": "Dark" } }] + }, + "--primary": { + "type": "color", + "value": [{ "value": "#2383E2" }, { "value": "#4a9eff", "theme": { "Mode": "Dark" } }] + }, + "--ring": { + "type": "color", + "value": [{ "value": "#2383E2" }, { "value": "#4a9eff", "theme": { "Mode": "Dark" } }] + } + } +} \ No newline at end of file From 6e4119c28b0811fb59eaf3e53367a86c99a9f6c9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 22 Feb 2026 17:40:20 +0100 Subject: [PATCH 2/7] feat: add Rust backend for GitHub vault operations New github.rs module with three commands: - github_list_repos: lists user's repos via GitHub API (paginated) - github_create_repo: creates a new repo with auto_init - clone_repo: git clone with HTTPS+token auth, configures remote Also extends Settings with github_token field (Rust + TypeScript), and adds GithubRepo type to shared types. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/github.rs | 237 ++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 22 +++- src-tauri/src/settings.rs | 8 ++ src/types.ts | 11 ++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/github.rs diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs new file mode 100644 index 00000000..3fd8e22e --- /dev/null +++ b/src-tauri/src/github.rs @@ -0,0 +1,237 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::process::Command; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GithubRepo { + pub name: String, + pub full_name: String, + pub description: Option, + pub private: bool, + pub clone_url: String, + pub html_url: String, + pub updated_at: Option, +} + +/// Lists the authenticated user's GitHub repositories. +pub async fn github_list_repos(token: &str) -> Result, String> { + let client = reqwest::Client::new(); + let mut all_repos: Vec = Vec::new(); + let mut page = 1u32; + + loop { + let url = format!( + "https://api.github.com/user/repos?per_page=100&sort=updated&page={}", + page + ); + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "Laputa-App") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| format!("GitHub API request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("GitHub API error {}: {}", status, body)); + } + + let repos: Vec = response + .json() + .await + .map_err(|e| format!("Failed to parse GitHub response: {}", e))?; + + let count = repos.len(); + all_repos.extend(repos); + + if count < 100 { + break; + } + page += 1; + if page > 10 { + break; // safety limit: 1000 repos max + } + } + + Ok(all_repos) +} + +#[derive(Debug, Deserialize, Serialize)] +struct CreateRepoResponse { + name: String, + full_name: String, + description: Option, + private: bool, + clone_url: String, + html_url: String, + updated_at: Option, +} + +/// Creates a new GitHub repository for the authenticated user. +pub async fn github_create_repo( + token: &str, + name: &str, + private: bool, +) -> Result { + let client = reqwest::Client::new(); + let body = serde_json::json!({ + "name": name, + "private": private, + "auto_init": true, + "description": "Laputa vault" + }); + + let response = client + .post("https://api.github.com/user/repos") + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "Laputa-App") + .header("X-GitHub-Api-Version", "2022-11-28") + .json(&body) + .send() + .await + .map_err(|e| format!("GitHub API request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if status.as_u16() == 422 && body.contains("name already exists") { + return Err("Repository name already exists on your account".to_string()); + } + return Err(format!("GitHub API error {}: {}", status, body)); + } + + let created: CreateRepoResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse GitHub response: {}", e))?; + + Ok(GithubRepo { + name: created.name, + full_name: created.full_name, + description: created.description, + private: created.private, + clone_url: created.clone_url, + html_url: created.html_url, + updated_at: created.updated_at, + }) +} + +/// Clones a GitHub repo to a local path using HTTPS + token auth. +pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result { + let dest = Path::new(local_path); + + if dest.exists() && dest.read_dir().map(|mut d| d.next().is_some()).unwrap_or(false) { + return Err(format!( + "Destination '{}' already exists and is not empty", + local_path + )); + } + + // Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/... + let auth_url = inject_token_into_url(url, token)?; + + let output = Command::new("git") + .args(["clone", "--progress", &auth_url, local_path]) + .output() + .map_err(|e| format!("Failed to run git clone: {}", e))?; + + if !output.status.success() { + // Clean up partial clone on failure + if dest.exists() { + let _ = std::fs::remove_dir_all(dest); + } + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git clone failed: {}", stderr)); + } + + // Configure the remote to use token auth for future pushes + configure_remote_auth(local_path, url, token)?; + + Ok(format!("Cloned to {}", local_path)) +} + +/// Injects an OAuth token into an HTTPS GitHub URL. +fn inject_token_into_url(url: &str, token: &str) -> Result { + if let Some(rest) = url.strip_prefix("https://github.com/") { + Ok(format!("https://oauth2:{}@github.com/{}", token, rest)) + } else if let Some(rest) = url.strip_prefix("https://") { + // Handle URLs that already have a host + Ok(format!("https://oauth2:{}@{}", token, rest)) + } else { + Err(format!("Unsupported URL format: {}. Use an HTTPS URL.", url)) + } +} + +/// Sets up the git remote to use token-based HTTPS auth. +fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> { + let auth_url = inject_token_into_url(original_url, token)?; + let vault = Path::new(local_path); + + let output = Command::new("git") + .args(["remote", "set-url", "origin", &auth_url]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to configure remote: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to set remote URL: {}", stderr)); + } + + // Also configure git user if not set + let _ = Command::new("git") + .args(["config", "user.email", "laputa@app.local"]) + .current_dir(vault) + .output(); + let _ = Command::new("git") + .args(["config", "user.name", "Laputa App"]) + .current_dir(vault) + .output(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_inject_token_basic_github_url() { + let url = "https://github.com/user/repo.git"; + let token = "gho_abc123"; + let result = inject_token_into_url(url, token).unwrap(); + assert_eq!(result, "https://oauth2:gho_abc123@github.com/user/repo.git"); + } + + #[test] + fn test_inject_token_generic_https_url() { + let url = "https://gitlab.com/user/repo.git"; + let token = "glpat-abc"; + let result = inject_token_into_url(url, token).unwrap(); + assert_eq!(result, "https://oauth2:glpat-abc@gitlab.com/user/repo.git"); + } + + #[test] + fn test_inject_token_ssh_url_rejected() { + let url = "git@github.com:user/repo.git"; + let result = inject_token_into_url(url, "token"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported URL format")); + } + + #[test] + fn test_clone_repo_nonempty_dest() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path(); + std::fs::write(path.join("existing.txt"), "data").unwrap(); + + let result = clone_repo("https://github.com/test/repo.git", "token", path.to_str().unwrap()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not empty")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7278a68d..1558191c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,13 @@ pub mod ai_chat; pub mod frontmatter; pub mod git; +pub mod github; pub mod settings; pub mod vault; use ai_chat::{AiChatRequest, AiChatResponse}; use git::{GitCommit, ModifiedFile}; +use github::GithubRepo; use settings::Settings; use vault::{VaultEntry, RenameResult}; use frontmatter::FrontmatterValue; @@ -90,6 +92,21 @@ fn save_settings(settings: Settings) -> Result<(), String> { settings::save_settings(settings) } +#[tauri::command] +async fn github_list_repos(token: String) -> Result, String> { + github::github_list_repos(&token).await +} + +#[tauri::command] +async fn github_create_repo(token: String, name: String, private: bool) -> Result { + github::github_create_repo(&token, &name, private).await +} + +#[tauri::command] +fn clone_repo(url: String, token: String, local_path: String) -> Result { + github::clone_repo(&url, &token, &local_path) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -142,7 +159,10 @@ pub fn run() { save_image, purge_trash, get_settings, - save_settings + save_settings, + github_list_repos, + github_create_repo, + clone_repo ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index a09208a5..baebb8b0 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -7,6 +7,7 @@ pub struct Settings { pub anthropic_key: Option, pub openai_key: Option, pub google_key: Option, + pub github_token: Option, } fn settings_path() -> Result { @@ -38,6 +39,7 @@ pub fn save_settings(settings: Settings) -> Result<(), String> { anthropic_key: settings.anthropic_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), openai_key: settings.openai_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), google_key: settings.google_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), + github_token: settings.github_token.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), }; let json = serde_json::to_string_pretty(&cleaned) @@ -65,6 +67,7 @@ mod tests { assert!(settings.anthropic_key.is_none()); assert!(settings.openai_key.is_none()); assert!(settings.google_key.is_none()); + assert!(settings.github_token.is_none()); } #[test] @@ -73,12 +76,14 @@ mod tests { anthropic_key: Some("sk-ant-test123".to_string()), openai_key: None, google_key: Some("AIza-test".to_string()), + github_token: Some("gho_xyz789".to_string()), }; let json = serde_json::to_string(&settings).unwrap(); let parsed: Settings = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.anthropic_key, Some("sk-ant-test123".to_string())); assert!(parsed.openai_key.is_none()); assert_eq!(parsed.google_key, Some("AIza-test".to_string())); + assert_eq!(parsed.github_token, Some("gho_xyz789".to_string())); } #[test] @@ -88,14 +93,17 @@ mod tests { anthropic_key: Some(" sk-ant-test ".to_string()), openai_key: Some(" ".to_string()), google_key: Some("".to_string()), + github_token: Some(" gho_abc ".to_string()), }; let cleaned = Settings { anthropic_key: settings.anthropic_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), openai_key: settings.openai_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), google_key: settings.google_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), + github_token: settings.github_token.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), }; assert_eq!(cleaned.anthropic_key, Some("sk-ant-test".to_string())); assert!(cleaned.openai_key.is_none()); assert!(cleaned.google_key.is_none()); + assert_eq!(cleaned.github_token, Some("gho_abc".to_string())); } } diff --git a/src/types.ts b/src/types.ts index 9a079238..98920915 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,17 @@ export interface Settings { anthropic_key: string | null openai_key: string | null google_key: string | null + github_token: string | null +} + +export interface GithubRepo { + name: string + full_name: string + description: string | null + private: boolean + clone_url: string + html_url: string + updated_at: string | null } export type SidebarSelection = From 861235981c54a9ee2ba71008a750879db821a707 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 22 Feb 2026 17:46:31 +0100 Subject: [PATCH 3/7] feat: GitHub vault modal with clone/create flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New GitHubVaultModal component with two tabs: - Clone Existing: search/browse user's repos, select one, choose local path, clone via HTTPS+token - Create New: enter repo name, toggle private/public, create via GitHub API then clone locally Integration: - "Connect GitHub repo" option in vault switcher menu - GitHub token field added to Settings panel - Cloned vaults auto-added to vault list and opened - Mock handlers for github_list_repos, github_create_repo, clone_repo - All existing tests updated for new github_token field (386 pass) Product decision: GitHub token stored in settings rather than OAuth flow — simpler first version, works with personal access tokens. Users can upgrade to OAuth later. Co-Authored-By: Claude Opus 4.6 --- src/App.test.tsx | 2 +- src/App.tsx | 30 +- src/components/GitHubVaultModal.tsx | 436 ++++++++++++++++++++++++++ src/components/SettingsPanel.test.tsx | 5 + src/components/SettingsPanel.tsx | 22 ++ src/components/StatusBar.tsx | 32 +- src/hooks/useSettings.test.ts | 3 + src/hooks/useSettings.ts | 1 + src/mock-tauri.ts | 21 +- 9 files changed, 541 insertions(+), 11 deletions(-) create mode 100644 src/components/GitHubVaultModal.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx index 8d213945..1c77c86d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -53,7 +53,7 @@ vi.mock('./mock-tauri', () => ({ if (cmd === 'get_modified_files') return [] if (cmd === 'get_note_content') return mockAllContent['/vault/project/test.md'] || '' if (cmd === 'get_file_history') return [] - if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null } + if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null } if (cmd === 'save_settings') return null return null }), diff --git a/src/App.tsx b/src/App.tsx index 7550da9c..19579f40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' import { Editor } from './components/Editor' @@ -9,6 +9,7 @@ import { Toast } from './components/Toast' import { CommitDialog } from './components/CommitDialog' import { StatusBar } from './components/StatusBar' import { SettingsPanel } from './components/SettingsPanel' +import { GitHubVaultModal } from './components/GitHubVaultModal' import { useVaultLoader } from './hooks/useVaultLoader' import { useSettings } from './hooks/useSettings' import { useNoteActions, generateUntitledName } from './hooks/useNoteActions' @@ -19,6 +20,7 @@ import { useKeyboardNavigation } from './hooks/useKeyboardNavigation' import { useUpdater } from './hooks/useUpdater' import { setApiKey } from './utils/ai-chat' import type { SidebarSelection, GitCommit } from './types' +import type { VaultOption } from './components/StatusBar' import './App.css' // Type declaration for mock content storage @@ -30,7 +32,7 @@ declare global { const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } -const VAULTS = isTauri() +const DEFAULT_VAULTS: VaultOption[] = isTauri() ? [ { label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' }, { label: 'Laputa', path: '/Users/luca/Laputa' }, @@ -58,9 +60,13 @@ function App() { const [showQuickOpen, setShowQuickOpen] = useState(false) const [showCommitDialog, setShowCommitDialog] = useState(false) const [toastMessage, setToastMessage] = useState(null) - const [vaultPath, setVaultPath] = useState(VAULTS[0].path) + const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path) const [showAIChat, setShowAIChat] = useState(false) const [showSettings, setShowSettings] = useState(false) + const [showGitHubVault, setShowGitHubVault] = useState(false) + const [extraVaults, setExtraVaults] = useState([]) + + const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults]) const vault = useVaultLoader(vaultPath) const { settings, saveSettings } = useSettings() @@ -94,6 +100,15 @@ function App() { notes.closeAllTabs() }, [notes]) + const handleVaultCloned = useCallback((path: string, label: string) => { + setExtraVaults(prev => { + if (prev.some(v => v.path === path)) return prev + return [...prev, { label, path }] + }) + handleSwitchVault(path) + setToastMessage(`Vault "${label}" cloned and opened`) + }, [handleSwitchVault]) + useEffect(() => { if (!notes.activeTabPath) { setGitHistory([]); return } vault.loadGitHistory(notes.activeTabPath).then(setGitHistory) @@ -197,12 +212,19 @@ function App() { /> - setShowSettings(true)} /> + setShowSettings(true)} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} /> setToastMessage(null)} /> setShowQuickOpen(false)} /> setShowCreateTypeDialog(false)} onCreate={handleCreateType} /> setShowCommitDialog(false)} /> setShowSettings(false)} /> + setShowGitHubVault(false)} + onVaultCloned={handleVaultCloned} + onOpenSettings={() => { setShowGitHubVault(false); setShowSettings(true) }} + /> ) } diff --git a/src/components/GitHubVaultModal.tsx b/src/components/GitHubVaultModal.tsx new file mode 100644 index 00000000..9ebb2330 --- /dev/null +++ b/src/components/GitHubVaultModal.tsx @@ -0,0 +1,436 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { GithubRepo } from '../types' + +function tauriCall(cmd: string, args: Record): Promise { + return isTauri() ? invoke(cmd, args) : mockInvoke(cmd, args) +} + +interface GitHubVaultModalProps { + open: boolean + githubToken: string | null + onClose: () => void + onVaultCloned: (path: string, label: string) => void + onOpenSettings: () => void +} + +type CloneStatus = 'idle' | 'cloning' | 'success' | 'error' + +function RepoListItem({ repo, selected, onSelect }: { repo: GithubRepo; selected: boolean; onSelect: () => void }) { + return ( +
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }} + className={`flex flex-col gap-1 rounded-md px-3 py-2.5 cursor-pointer transition-colors ${ + selected ? 'bg-accent' : 'hover:bg-accent/50' + }`} + data-testid={`repo-item-${repo.name}`} + > +
+ {repo.full_name} + + {repo.private ? 'Private' : 'Public'} + +
+ {repo.description && ( + {repo.description} + )} +
+ ) +} + +function CloneTab({ + repos, + loading, + search, + setSearch, + selectedRepo, + setSelectedRepo, + localPath, + setLocalPath, + onClone, + cloneStatus, + cloneError, +}: { + repos: GithubRepo[] + loading: boolean + search: string + setSearch: (v: string) => void + selectedRepo: GithubRepo | null + setSelectedRepo: (r: GithubRepo) => void + localPath: string + setLocalPath: (v: string) => void + onClone: () => void + cloneStatus: CloneStatus + cloneError: string | null +}) { + const filtered = repos.filter(r => + r.full_name.toLowerCase().includes(search.toLowerCase()) || + (r.description?.toLowerCase().includes(search.toLowerCase()) ?? false) + ) + + return ( +
+ setSearch(e.target.value)} + data-testid="github-repo-search" + /> + +
+ {loading ? ( +
+ Loading repositories... +
+ ) : filtered.length === 0 ? ( +
+ {search ? 'No repos match your search' : 'No repositories found'} +
+ ) : ( +
+ {filtered.map(repo => ( + setSelectedRepo(repo)} + /> + ))} +
+ )} +
+ +
+ + setLocalPath(e.target.value)} + placeholder="~/Vaults/repo-name" + data-testid="github-clone-path" + /> +
+ + {cloneError && ( +

{cloneError}

+ )} + + + + {filtered.length} repo{filtered.length !== 1 ? 's' : ''} found + + + +
+ ) +} + +function CreateTab({ + repoName, + setRepoName, + isPrivate, + setIsPrivate, + localPath, + setLocalPath, + onCreate, + cloneStatus, + cloneError, +}: { + repoName: string + setRepoName: (v: string) => void + isPrivate: boolean + setIsPrivate: (v: boolean) => void + localPath: string + setLocalPath: (v: string) => void + onCreate: () => void + cloneStatus: CloneStatus + cloneError: string | null +}) { + return ( +
+
+ + setRepoName(e.target.value)} + placeholder="my-vault" + data-testid="github-repo-name" + /> + {repoName && ( + + github.com/you/{repoName} + + )} +
+ +
+ +
+ + +
+
+ +
+ + setLocalPath(e.target.value)} + placeholder="~/Vaults/my-vault" + data-testid="github-create-path" + /> +
+ + {cloneError && ( +

{cloneError}

+ )} + + + + +
+ ) +} + +function CloningProgress({ repoName }: { repoName: string }) { + return ( +
+
+

Cloning {repoName}...

+

This may take a moment for large repositories

+
+ ) +} + +export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings }: GitHubVaultModalProps) { + const [tab, setTab] = useState('clone') + const [repos, setRepos] = useState([]) + const [loading, setLoading] = useState(false) + const [search, setSearch] = useState('') + const [selectedRepo, setSelectedRepo] = useState(null) + const [clonePath, setClonePath] = useState('') + const [repoName, setRepoName] = useState('') + const [isPrivate, setIsPrivate] = useState(true) + const [createPath, setCreatePath] = useState('') + const [cloneStatus, setCloneStatus] = useState('idle') + const [cloneError, setCloneError] = useState(null) + const loadedRef = useRef(false) + + const loadRepos = useCallback(async () => { + if (!githubToken) return + setLoading(true) + try { + const result = await tauriCall('github_list_repos', { token: githubToken }) + setRepos(result) + } catch (err) { + console.error('Failed to load GitHub repos:', err) + setCloneError(`Failed to load repos: ${err}`) + } finally { + setLoading(false) + } + }, [githubToken]) + + useEffect(() => { + if (open && githubToken && !loadedRef.current) { + loadedRef.current = true + loadRepos() + } + if (!open) { + loadedRef.current = false + } + }, [open, githubToken, loadRepos]) + + // Auto-fill clone path when repo selected + useEffect(() => { + if (selectedRepo) { + setClonePath(`~/Vaults/${selectedRepo.name}`) + } + }, [selectedRepo]) + + // Auto-fill create path when name changes + useEffect(() => { + if (repoName) { + setCreatePath(`~/Vaults/${repoName}`) + } + }, [repoName]) + + const resetState = useCallback(() => { + setTab('clone') + setSearch('') + setSelectedRepo(null) + setClonePath('') + setRepoName('') + setIsPrivate(true) + setCreatePath('') + setCloneStatus('idle') + setCloneError(null) + }, []) + + const handleClose = useCallback(() => { + resetState() + onClose() + }, [onClose, resetState]) + + const handleClone = useCallback(async () => { + if (!selectedRepo || !clonePath.trim() || !githubToken) return + setCloneStatus('cloning') + setCloneError(null) + try { + await tauriCall('clone_repo', { + url: selectedRepo.clone_url, + token: githubToken, + localPath: clonePath.trim(), + }) + setCloneStatus('success') + onVaultCloned(clonePath.trim(), selectedRepo.name) + handleClose() + } catch (err) { + setCloneStatus('error') + setCloneError(`Clone failed: ${err}`) + } + }, [selectedRepo, clonePath, githubToken, onVaultCloned, handleClose]) + + const handleCreate = useCallback(async () => { + if (!repoName.trim() || !createPath.trim() || !githubToken) return + setCloneStatus('cloning') + setCloneError(null) + try { + const repo = await tauriCall('github_create_repo', { + token: githubToken, + name: repoName.trim(), + private: isPrivate, + }) + await tauriCall('clone_repo', { + url: repo.clone_url, + token: githubToken, + localPath: createPath.trim(), + }) + setCloneStatus('success') + onVaultCloned(createPath.trim(), repo.name) + handleClose() + } catch (err) { + setCloneStatus('error') + setCloneError(`${err}`) + } + }, [repoName, createPath, githubToken, isPrivate, onVaultCloned, handleClose]) + + if (!githubToken) { + return ( + { if (!isOpen) handleClose() }}> + + + Connect GitHub Repo + Connect your GitHub account to clone or create vaults backed by GitHub repos. + +
+

+ You need to connect your GitHub account first. Add your GitHub token in Settings. +

+ +
+
+
+ ) + } + + const cloningRepoName = tab === 'clone' ? (selectedRepo?.full_name ?? '') : repoName + + return ( + { if (!isOpen) handleClose() }}> + + + Connect GitHub Repo + Clone an existing repo or create a new one as a vault. + + + {cloneStatus === 'cloning' ? ( + + ) : ( + + + Clone Existing + Create New + + + + + + + + + + + )} + + + ) +} diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index e669428b..5be9fc8e 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -7,12 +7,14 @@ const emptySettings: Settings = { anthropic_key: null, openai_key: null, google_key: null, + github_token: null, } const populatedSettings: Settings = { anthropic_key: 'sk-ant-api03-test123', openai_key: 'sk-openai-test456', google_key: null, + github_token: null, } describe('SettingsPanel', () => { @@ -74,6 +76,7 @@ describe('SettingsPanel', () => { anthropic_key: 'sk-ant-test', openai_key: null, google_key: null, + github_token: null, }) expect(onClose).toHaveBeenCalled() }) @@ -92,6 +95,7 @@ describe('SettingsPanel', () => { anthropic_key: null, openai_key: 'sk-openai-test456', google_key: null, + github_token: null, }) }) @@ -131,6 +135,7 @@ describe('SettingsPanel', () => { anthropic_key: 'sk-ant-test', openai_key: null, google_key: null, + github_token: null, }) }) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 0b9ffd0b..e77cc1d7 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -73,12 +73,14 @@ export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanel const [anthropicKey, setAnthropicKey] = useState('') const [openaiKey, setOpenaiKey] = useState('') const [googleKey, setGoogleKey] = useState('') + const [githubToken, setGithubToken] = useState('') useEffect(() => { if (open) { setAnthropicKey(settings.anthropic_key ?? '') setOpenaiKey(settings.openai_key ?? '') setGoogleKey(settings.google_key ?? '') + setGithubToken(settings.github_token ?? '') } }, [open, settings]) @@ -89,6 +91,7 @@ export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanel anthropic_key: anthropicKey.trim() || null, openai_key: openaiKey.trim() || null, google_key: googleKey.trim() || null, + github_token: githubToken.trim() || null, } onSave(trimmed) onClose() @@ -164,6 +167,25 @@ export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanel onChange={setGoogleKey} onClear={() => setGoogleKey('')} /> + +
+ +
+
+ GitHub +
+
+ Personal access token for cloning and syncing vaults with GitHub. +
+
+ + setGithubToken('')} + />
{/* Footer */} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index a1972b0d..9ada3e94 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check } from 'lucide-react' +import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github } from 'lucide-react' export interface VaultOption { label: string @@ -12,6 +12,8 @@ interface StatusBarProps { vaults: VaultOption[] onSwitchVault: (path: string) => void onOpenSettings?: () => void + onConnectGitHub?: () => void + hasGitHub?: boolean } function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) { @@ -32,7 +34,7 @@ function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isAc ) } -function VaultMenu({ vaults, vaultPath, onSwitchVault }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void }) { +function VaultMenu({ vaults, vaultPath, onSwitchVault, onConnectGitHub, hasGitHub }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void; onConnectGitHub?: () => void; hasGitHub?: boolean }) { const [open, setOpen] = useState(false) const menuRef = useRef(null) const activeVault = vaults.find((v) => v.path === vaultPath) @@ -53,8 +55,28 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault }: { vaults: VaultOption[] {activeVault?.label ?? 'Vault'} {open && ( -
+
{vaults.map((v) => { onSwitchVault(v.path); setOpen(false) }} />)} + {onConnectGitHub && ( + <> +
+
{ onConnectGitHub(); setOpen(false) }} + style={{ + display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, + cursor: 'pointer', background: 'transparent', + color: hasGitHub ? 'var(--muted-foreground)' : 'var(--accent-blue)', fontSize: 12, + }} + onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }} + onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }} + data-testid="vault-menu-connect-github" + > + + Connect GitHub repo +
+ + )}
)}
@@ -65,11 +87,11 @@ const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const const SEP_STYLE = { color: 'var(--border)' } as const -export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenSettings }: StatusBarProps) { +export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenSettings, onConnectGitHub, hasGitHub }: StatusBarProps) { return (