Commit737fbcc7Recorded8 Sep 2026Repositoryslate
Add staged Git commits and simplify Git controls
Changed
packages/slate-cli/design/auth-prompts.md | 4 ++--
packages/slate-cli/design/git-commits.md | 42 ++++++++++++++++++++++++++++++++++
packages/slate-cli/src/slate-cli/auth/askpass.sgl | 8 ++++---
packages/slate-cli/src/slate-cli/dispatch.sgl | 5 ++++
packages/slate-cli/src/slate-cli/git/commit.sgl | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
packages/slate-cli/src/slate-cli/main.sgl | 16 +++++++++++++
packages/slate-cli/test/test-git-commit.sgl | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
packages/slate/assets/index.html | 56 +++++++++++++++++++++++++++++++++++----------
packages/slate/assets/sw.js | 2 +-
packages/slate/src/slate/accessory.sgl | 3 +++
packages/slate/src/slate/commands/git.sgl | 2 ++
packages/slate/src/slate/commands/git/commit.sgl | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
packages/slate/src/slate/commands/help.sgl | 12 ++++++++++
packages/slate/src/slate/main.sgl | 5 +++-
packages/slate/src/slate/modes/editor.sgl | 1 +
packages/slate/src/slate/modes/git-commit.sgl | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
packages/slate/src/slate/modes/git-log.sgl | 28 ++++++++---------------
packages/slate/src/slate/modes/git-status.sgl | 43 +++++------------------------------
packages/slate/test/git-auth-fixture.mjs | 16 ++++++++++---
packages/slate/test/git-branch-browser-gate.mjs | 11 +++++++--
packages/slate/test/git-commit-browser-gate.mjs | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
packages/slate/test/git-commit-command-test.sgl | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
packages/slate/test/git-status-browser-gate.mjs | 10 +++++---
packages/slate/test/git-status-view-test.sgl | 7 +++---
packages/slate/test/help-command-test.sgl | 11 ++++++++-
25 files changed, 909 insertions(+), 86 deletions(-)Diff
packages/slate-cli/design/auth-prompts.mdmodified
@@ -1,7 +1,7 @@
1
# Node authentication prompts 2
−3
Slate's Git status Fetch command (`f` or the toolbar button) uses a real Git−4
ASKPASS helpers for HTTPS credentials and SSH key passphrases, passwords or+3
Slate's Git status Fetch command (`f` or Git → Fetch in the accessory menu) uses+4
Git ASKPASS helpers for HTTPS credentials and SSH key passphrases, passwords or 5
verification codes. It 6
fetches the current branch's configured remote, falling back to `origin`, and 7
refreshes status when finished. This first adapter requires a local browserpackages/slate-cli/design/git-commits.mdadded
@@ -0,0 +1,42 @@
+1
# Creating commits+2
+3
In Git status, stage the intended files or hunks, then use `c c` or **Git → Commit**+4
in the accessory bar. The message opens in Slate's ordinary editor. Wide panes+5
show the staged diff alongside it; narrow panes provide Write and Review views.+6
Use `C-c C-c` or Commit to submit, and `C-c C-k` or Close to return to status.+7
Closing retains the draft in the current session. Reloading Slate does not persist+8
these drafts.+9
+10
Slate runs ordinary `git commit --file <private-message-file> --cleanup=whitespace`.+11
Only staged changes are committed. Markdown headings are preserved, and Git's+12
normal hooks and configured signing run. GPG signing uses the same operation-scoped+13
authentication broker as Fetch; shared agents need the documented pinentry bridge.+14
The adapter currently requires an authenticated local browser connection to a Unix+15
node. It does not install user-wide ASKPASS environment variables.+16
+17
The node's `git/commit` request takes `repo`, `message`, `expected-snapshot`, and+18
`expected-branch`. Messages must be nonblank, contain no NUL, and fit in 64 KiB of+19
UTF-8. The node checks write grants for the worktree and Git administrative+20
directories. Before execution it checks HEAD, branch, and the index against the+21
reviewed snapshot. Unstaged edits do not invalidate the staged review. These are+22
preflight checks; ordinary Git locks and hooks still govern the commit, and an+23
external process can change the index after preflight.+24
+25
The version-1 reply includes `repo`, `applied`, `uncertain`, `oid`, `status`,+26
`message`, and bounded plain-text diagnostic `details`. Failed hooks preserve the+27
message and expose their output in the composer. Pending submissions cannot be+28
sent twice. A lost reply or uncertain outcome requires inspecting history and+29
explicitly refreshing the review before retrying; reconnect never resends a+30
commit automatically.+31
+32
This command supports normal commits, including the first commit in a repository.+33
It rejects unresolved conflicts and operations in progress such as merge or+34
rebase. Amend, fixup, and operation-continuation flows are separate commands to+35
add later.+36
+37
Run the node and UI tests with `sigil test slate-cli --no-strict+38
packages/slate-cli/test/test-git-commit.sgl` and `sigil test slate --no-strict+39
packages/slate/test/git-commit-command-test.sgl`. With both builds available,+40
`node packages/slate/test/git-commit-browser-gate.mjs` exercises real initial and+41
signed commits, hook failure recovery, keyboard controls, and mobile menus using+42
disposable repositories and keys.packages/slate-cli/src/slate-cli/auth/askpass.sglmodified
@@ -4,7 +4,7 @@
4
(import (sigil async) (sigil socket) (sigil io) (sigil fs) (sigil path) 5
(sigil process) (sigil string) (sigil time) (sigil math) (sigil crypto) 6
(slate-cli auth ipc) (slate-cli auth prompts) (slate-cli listen))−7
(export call-with-git-askpass call-with-auth-helper+7
(export call-with-git-askpass call-with-git-auth call-with-auth-helper 8
askpass-helper-main auth-helper-request helper-executable) 9
(begin 10
(define (close-socket socket)@@ -61,12 +61,14 @@
61
0)) 62
63
(define (call-with-git-askpass ctx remote run . transport)+64
(call-with-git-auth ctx (string-append "Git fetch · " remote)+65
(if (pair? transport) (car transport) "git") run))+66
(define (call-with-git-auth ctx title kind run) 67
;; These presence-based Git flags cannot be disabled with KEY=0 overrides. 68
(when (or (getenv "GIT_CURL_VERBOSE") (getenv "GIT_SSL_NO_VERIFY")) 69
(error "operation-failed" 70
"Restart the Slate node without GIT_CURL_VERBOSE or GIT_SSL_NO_VERIFY to use authenticated Git"))−68
(call-with-auth-helper ctx (string-append "Git fetch · " remote)−69
(if (pair? transport) (car transport) "git")+71
(call-with-auth-helper ctx title kind 72
(lambda (environment cancelled?) 73
(run 74
(append environmentpackages/slate-cli/src/slate-cli/dispatch.sglmodified
@@ -331,6 +331,11 @@
331
(if fetch (reply-inline id (fetch grants args)) 332
(reply-err id "credential-routing-unavailable" 333
"Git fetch currently requires the local node connection"))))+334
((string=? verb "git/commit")+335
(let ((commit (dict-ref daemon git-commit: #f)))+336
(if commit (reply-inline id (commit grants args))+337
(reply-err id "credential-routing-unavailable"+338
"Committing currently requires the local node connection")))) 339
((string=? verb "git/log") (reply-inline id (git-log grants args))) 340
((string=? verb "git/show") (reply-inline id (git-show grants args))) 341
((string=? verb "git/diff") (reply-inline id (git-diff grants args)))packages/slate-cli/src/slate-cli/git/commit.sgladded
@@ -0,0 +1,94 @@
+1
;;; Ordinary staged commits through Git's hooks and signing configuration.+2
(define-library (slate-cli git commit)+3
(import (sigil io) (sigil fs) (sigil path) (sigil string) (sigil math)+4
(sigil system grant) (slate-cli git exec) (slate-cli git status))+5
(export git-commit)+6
(begin+7
(define *pending* '())+8
(define (same-index? expected actual)+9
(and (dict? expected) (dict? actual)+10
(string? (dict-ref expected head-oid: #f))+11
(string? (dict-ref expected index-id: #f))+12
(equal? (dict-ref expected head-oid: #f) (dict-ref actual head-oid: #f))+13
(equal? (dict-ref expected index-id: #f) (dict-ref actual index-id: #f))))+14
(define (validate! grants args)+15
(let* ((repo (dict-ref args repo: #f))+16
(status (git-status grants #{ repo: repo recent-limit: 0 })))+17
(grant-assert! grants 'fs-write (dict-ref repo worktree: ""))+18
(grant-assert! grants 'fs-write (dict-ref repo common-dir: ""))+19
(let ((admin (git-exec grants (dict-ref repo worktree: "")+20
(list "rev-parse" "--absolute-git-dir"))))+21
(unless (= (dict-ref admin exit: -1) 0)+22
(error "operation-failed" "Could not locate the Git administration directory."))+23
(grant-assert! grants 'fs-write (string-trim (dict-ref admin stdout: ""))))+24
(unless (and (same-index? (dict-ref args expected-snapshot: #f)+25
(dict-ref status snapshot: #f))+26
(equal? (dict-ref args expected-branch: #f)+27
(dict-ref (dict-ref status branch: #{}) head: #f)))+28
(error "stale-snapshot" "HEAD or staged changes moved. Refresh the commit review before submitting."))+29
(when (dict-ref status operation: #f)+30
(error "operation-failed" "Finish the current merge, rebase or other Git operation first."))+31
(let ((entries (dict-ref status entries: '())))+32
(when (any (lambda (entry) (equal? (dict-ref entry kind: #f) "conflict")) entries)+33
(error "operation-failed" "Resolve conflicts before committing."))+34
(unless (any (lambda (entry)+35
(and (not (equal? (dict-ref entry kind: #f) "untracked"))+36
(not (equal? (dict-ref entry index-state: ".") ".")))) entries)+37
(error "operation-failed" "Stage changes before committing.")))+38
status))+39
(define (diagnostic result)+40
(let* ((text (string-trim (string-append (dict-ref result stderr: "") "\n"+41
(dict-ref result stdout: ""))))+42
(short (substring text 0 (min 3000 (string-length text)))))+43
(list->string+44
(filter (lambda (ch)+45
(or (memv ch '(#\newline #\tab))+46
(and (>= (char->integer ch) 32) (not (= (char->integer ch) 127)))))+47
(string->list short)))))+48
(define (git-commit grants args execute)+49
(let* ((message (dict-ref args message: #f))+50
(repo (dict-ref args repo: #f)))+51
(unless (and (string? message)+52
(> (string-length (string-trim message)) 0)+53
(<= (bytevector-length (string->utf8 message)) 65536)+54
(not (string-contains? message (string (integer->char 0)))))+55
(error "operation-failed" "Enter a commit message (up to 64 KiB)."))+56
(validate! grants args)+57
(let ((key (dict-ref repo worktree: "")))+58
(when (member key *pending*)+59
(error "operation-busy" "A commit is already running for this worktree."))+60
(dynamic-wind+61
(lambda () (set! *pending* (cons key *pending*)))+62
(lambda ()+63
(call-with-temp-directory+64
(lambda (directory)+65
(let ((file (path-join directory "message")))+66
(write-file-string file message)+67
;; Recheck immediately before Git takes its own locks.+68
;; Worktree-only edits do not invalidate staged review.+69
(validate! grants args)+70
(let* ((result (execute (dict-ref repo worktree: "")+71
(list "commit" "--file" file "--cleanup=whitespace")))+72
(fresh (guard (e (#t #f))+73
(git-status grants #{ repo: repo recent-limit: 20 })))+74
(head (and fresh (dict-ref (dict-ref fresh snapshot: #{}) head-oid: #f)))+75
(moved? (and head (not (equal? head+76
(dict-ref (dict-ref args expected-snapshot: #{}) head-oid: #f)))))+77
(ok? (and (= (dict-ref result exit: -1) 0)+78
(not (dict-ref result cancelled?: #f))+79
(not (dict-ref result timeout?: #f))+80
(not (dict-ref result output-exceeded?: #f))+81
(not (dict-ref result read-error?: #f)))))+82
#{ version: 1 repo: repo applied: (and ok? #t)+83
uncertain: (and (not ok?) (or moved? (not fresh)) #t)+84
oid: (and moved? head) status: fresh+85
message:+86
(cond (ok? "Commit created.")+87
((or moved? (not fresh)) "Commit outcome is uncertain. Inspect history before trying again.")+88
((dict-ref result cancelled?: #f) "Commit cancelled. Your draft is preserved.")+89
((dict-ref result timeout?: #f) "Commit timed out. Your draft is preserved.")+90
(else "Git could not create the commit. Your draft is preserved."))+91
details: (if ok? "" (diagnostic result)) })))+92
recursive: #t))+93
(lambda ()+94
(set! *pending* (filter (lambda (item) (not (equal? item key))) *pending*)))))))))packages/slate-cli/src/slate-cli/main.sglmodified
@@ -41,6 +41,7 @@
41
(slate-cli auth pinentry routing) 42
(slate-cli auth gpg-test) 43
(slate-cli git remote)+44
(slate-cli git commit) 45
(slate-cli git exec) 46
(slate-cli peercred) 47
(slate-cli wire)@@ -2800,6 +2801,20 @@
2801
(dispatch-req (fetch-dispatch-daemon daemon ctx) 2802
(conn-origin ctx) (conn-grants daemon ctx) env))) 2803
(lambda () (conn-state-set! ctx "git-fetch-busy" #f)))))))+2804
(define (handle-git-commit! daemon ctx env)+2805
(go+2806
(send-reply! ctx+2807
(dispatch-req+2808
(dict-set daemon git-commit:+2809
(lambda (grants args)+2810
(call-with-git-auth ctx "Git commit" "gpg"+2811
(lambda (environment cancelled?)+2812
(git-commit grants args+2813
(lambda (cwd argv)+2814
(git-exec grants cwd argv timeout: 300 output-limit: 65536+2815
environment: (append environment (list "GIT_EDITOR=true"))+2816
cancelled?: cancelled? process-group?: #t streaming?: #t)))))))+2817
(conn-origin ctx) (conn-grants daemon ctx) env)))) 2818
(define (handle-req daemon ctx env) 2819
(vector-set! (dict-ref daemon enclave-control-ready: #()) 0 2820
(node-session-done?))@@ -2813,6 +2828,7 @@
2828
(string=? (substring verb 0 8) "pairing/")) 2829
(handle-pairing-management! daemon ctx env)) 2830
((string=? verb "git/fetch") (handle-git-fetch! daemon ctx env))+2831
((string=? verb "git/commit") (handle-git-commit! daemon ctx env)) 2832
(else 2833
(send-reply! ctx 2834
(dispatch-req daemon (conn-origin ctx) (conn-grants daemon ctx) env)))))))packages/slate-cli/test/test-git-commit.sgladded
@@ -0,0 +1,96 @@
+1
(import (sigil test) (sigil fs) (sigil path) (sigil string) (sigil process)+2
(sigil system grant) (slate-cli git exec) (slate-cli git status)+3
(slate-cli git commit))+4
+5
(define (with-repo proc)+6
(call-with-temp-directory+7
(lambda (root)+8
(let ((grants (make-grants principal: "commit-test")))+9
(grant-add! grants (string-append "fs:rw:" root))+10
(grant-add! grants "exec:full")+11
(define (git args)+12
(let ((result (git-exec grants root args)))+13
(assert-equal 0 (dict-ref result exit: -1))+14
(dict-ref result stdout: "")))+15
(git '("init" "-q" "--initial-branch=main"))+16
(git '("config" "user.name" "Slate Fixture"))+17
(git '("config" "user.email" "fixture@example.invalid"))+18
(git '("config" "commit.gpgsign" "false"))+19
(let ((repo (dict-ref (git-discover grants #{ path: root }) node-repo: #f)))+20
(define (args message)+21
(let ((status (git-status grants #{ repo: repo })))+22
#{ repo: repo message: message expected-snapshot: (dict-ref status snapshot: #{})+23
expected-branch: (dict-ref (dict-ref status branch: #{}) head: "") }))+24
(define (execute cwd argv) (git-exec grants cwd argv streaming?: #t))+25
(proc root grants repo git args execute))))+26
recursive: #t))+27
+28
(test "initial commit uses staged content and preserves Markdown message"+29
(with-repo+30
(lambda (root grants repo git args execute)+31
(write-file-string (path-join root "file") "staged\n")+32
(git '("add" "file"))+33
(let ((request (args "Initial commit\n\n# Details\n\nKeep **Markdown**.\n")))+34
(write-file-string (path-join root "file") "unstaged edit\n")+35
(let ((reply (git-commit grants request execute)))+36
(assert-true (dict-ref reply applied: #f))+37
(assert-equal "staged\n" (git '("show" "HEAD:file")))+38
(assert-equal "unstaged edit\n" (read-file-string (path-join root "file")))+39
(assert-true (string-contains? (git '("log" "-1" "--format=%B")) "# Details"))+40
(assert-error (git-commit grants request execute))+41
(assert-equal "1\n" (git '("rev-list" "--count" "HEAD"))))))))+42
+43
(test "commit refuses empty message, unstaged-only work and changed index"+44
(with-repo+45
(lambda (root grants repo git args execute)+46
(write-file-string (path-join root "file") "one\n")+47
(assert-error (git-commit grants (args "message") execute))+48
(git '("add" "file"))+49
(assert-error (git-commit grants (args " \n\t") execute))+50
(let ((request (args "message")))+51
(write-file-string (path-join root "file") "two\n")+52
(git '("add" "file"))+53
(assert-error (git-commit grants request execute))))))+54
+55
(test "commit preserves ordinary hooks and returns their failure output"+56
(with-repo+57
(lambda (root grants repo git args execute)+58
(let ((hook (path-join root ".git/hooks/pre-commit")))+59
(write-file-string hook "#!/bin/sh\necho 'Run the formatter first' >&2\nexit 1\n")+60
(assert-equal 0 (process-run "chmod" "700" hook))+61
(write-file-string (path-join root "file") "one\n")+62
(git '("add" "file"))+63
(let* ((request (args "message")) (reply (git-commit grants request execute)))+64
(assert-false (dict-ref reply applied: #f))+65
(assert-false (dict-ref reply uncertain: #f))+66
(assert-true (string-contains? (dict-ref reply details: "") "Run the formatter first"))+67
(delete-file hook)+68
(assert-true (dict-ref (git-commit grants request execute) applied: #f)))))))+69
+70
(test "commit rejects reentrant submission and releases its operation lock"+71
(with-repo+72
(lambda (root grants repo git args execute)+73
(write-file-string (path-join root "file") "one\n")+74
(git '("add" "file"))+75
(let ((request (args "message")))+76
(assert-error+77
(git-commit grants request+78
(lambda (cwd argv)+79
(assert-error (git-commit grants request execute))+80
(error "test launch failure"))))+81
(assert-true (dict-ref (git-commit grants request execute) applied: #f))))))+82
+83
(test "a ref update followed by failed transport has an uncertain outcome"+84
(with-repo+85
(lambda (root grants repo git args execute)+86
(write-file-string (path-join root "file") "one\n")+87
(git '("add" "file"))+88
(let* ((request (args "message"))+89
(reply (git-commit grants request+90
(lambda (cwd argv)+91
(execute cwd argv)+92
#{ exit: -1 cancelled?: #t }))))+93
(assert-false (dict-ref reply applied: #f))+94
(assert-true (dict-ref reply uncertain: #f))+95
(assert-error (git-commit grants request execute))+96
(assert-equal "1\n" (git '("rev-list" "--count" "HEAD")))))))packages/slate/assets/index.htmlmodified
@@ -2279,6 +2279,35 @@
2279
.splash-wordmark { color: #B4832B; } 2280
.splash-phrase { color: #8A94A6; } 2281
}+2282
.git-compose { container:compose / inline-size; display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; color:var(--fg-0); }+2283
.git-compose-header { padding:var(--sp-3); border-bottom:1px solid var(--edge-1); }+2284
.git-compose-title, .git-compose-actions, .git-compose-footer { display:flex; align-items:center; gap:var(--sp-3); }+2285
.git-compose-title { margin-bottom:var(--sp-2); flex-wrap:wrap; }+2286
.git-compose-title > span { color:var(--fg-2); font-family:var(--font-code); }+2287
.git-compose-path { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:10ch; }+2288
.git-compose button { background:var(--bg-2); color:var(--fg-0); border:1px solid var(--edge-1); border-radius:var(--r-1); padding:8px 12px; min-height:40px; font:inherit; }+2289
.git-compose button:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }+2290
.git-compose-workbench { display:flex; flex:1; min-height:0; min-width:0; }+2291
.git-compose-message, .git-compose-review { display:flex; flex:1; flex-direction:column; min-width:0; min-height:0; }+2292
.git-compose-review { border-left:1px solid var(--edge-1); }+2293
.git-compose-label { padding:10px 14px; font-size:var(--text-sm); border-bottom:1px solid var(--edge-1); }+2294
.git-compose-label span { display:block; margin-top:4px; color:var(--fg-2); }+2295
.git-compose-review-body { flex:1; min-height:0; overflow:auto; }+2296
.git-compose-review .slate-diff-control { border:0; border-radius:0; }+2297
.git-compose-footer { flex-wrap:wrap; padding:var(--sp-3); border-top:1px solid var(--edge-1); }+2298
.git-compose-footer > span { color:var(--fg-2); font-size:var(--text-sm); flex:1; }+2299
.git-compose .git-compose-submit { background:var(--accent); color:var(--bg-0); border-color:var(--accent); }+2300
.git-compose button:disabled { opacity:.5; }+2301
.git-compose-error { padding:10px 14px; border-bottom:1px solid var(--edge-1); background:var(--bg-2); }+2302
.git-compose-error pre { white-space:pre-wrap; overflow-wrap:anywhere; max-height:160px; overflow:auto; }+2303
@container compose (max-width: 760px) {+2304
.git-compose[data-review="false"] .git-compose-review { display:none; }+2305
.git-compose[data-review="true"] .git-compose-message { display:none; }+2306
.git-compose-review { border-left:0; }+2307
.git-compose-actions { gap:6px; }+2308
.git-compose-actions button { padding:6px 8px; }+2309
.git-compose-footer > span { flex-basis:100%; order:3; }+2310
} 2311
.git-buffer { position:relative; min-width: 0; max-width: 100%; padding: var(--sp-3); overflow: auto; font-family: var(--font-code); 2312
font-size: var(--text-code); color: var(--fg-0); } 2313
.git-buffer-header { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto;@@ -2375,7 +2404,6 @@
2404
.git-log-message .md-body { max-width:none; margin:0; font:inherit; font-size:.9em; line-height:1.6; } 2405
.git-log-note { color:var(--fg-2); font-size:.85em; } 2406
.git-log-inspection > p { padding:0 22px; }−2378
.git-log .git-command-strip { flex:none; } 2407
@container history (min-width: 960px) { 2408
.git-log[data-layout="auto"] .git-log-workbench { display:grid; grid-template-columns:minmax(0,45%) minmax(0,55%); grid-template-rows:minmax(0,1fr); overflow:hidden; } 2409
.git-log[data-layout="auto"] .git-log-review { border-top:0; border-left:1px solid var(--edge-1); }@@ -2440,7 +2468,6 @@
2468
.git-repository-origin { margin-left:auto; color:var(--fg-2); font-size:.85em; 2469
min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } 2470
.git-repository-origin::before { content:'●'; color:var(--ok); font-size:.65em; margin-right:8px; }−2443
.git-status .git-header-icon { border:0; flex:none; } 2471
.git-branch-line { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:8px 0; font-size:.92em; } 2472
.git-branch-line .icon { color:var(--accent); } 2473
.git-branch-name { overflow-wrap:anywhere; }@@ -2503,12 +2530,6 @@
2530
.git-review-prompt button { min-height:34px; padding:4px 12px; border:1px solid var(--edge-1); 2531
border-radius:var(--r-1); background:var(--bg-2); color:var(--fg-1); font:inherit; } 2532
.git-status kbd { font:inherit; font-size:.9em; color:var(--accent); margin-right:7px; white-space:nowrap; }−2506
.git-command-strip { flex:none; display:flex; align-items:center; gap:2px; flex-wrap:wrap;−2507
padding:6px 12px; border-top:1px solid var(--edge-1); background:var(--bg-1); }−2508
.git-command-strip button { min-height:28px; border:0; background:transparent; color:var(--fg-2);−2509
padding:2px 8px; font:inherit; font-size:.82em; }−2510
.git-command-strip button:hover { color:var(--fg-0); background:var(--bg-2); }−2511
.git-selection-scope { margin-left:auto; font-size:.75em; color:var(--fg-2); } 2533
.git-clean-state { display:flex; flex-direction:column; gap:8px; padding:36px 24px; color:var(--fg-2); } 2534
.git-clean-state strong { color:var(--ok); font-weight:400; } 2535
@media (pointer:fine) {@@ -2518,7 +2539,7 @@
2539
} 2540
@media (pointer:coarse) { 2541
.git-status .git-row, .git-status .git-row button, .git-layout-control button,−2521
.git-command-strip button, .git-status .slate-diff-hunk { min-height:44px; }+2542
.git-status .slate-diff-hunk { min-height:44px; } 2543
.git-status .git-entry-diff, .git-status .git-entry-mutation, .git-status .git-entry-discard { min-width:36px; } 2544
} 2545
@container git (max-width:639px) {@@ -2526,12 +2547,11 @@
2547
.git-repository-title { flex-wrap:wrap; gap:6px; } 2548
.git-repository-name { flex:1; } 2549
.git-repository-origin { order:4; flex-basis:100%; margin:0; }−2529
.git-repository-detail, .git-selection-scope { display:none; }+2550
.git-repository-detail { display:none; } 2551
.git-outline { padding:8px 4px 20px; } 2552
.git-status .git-entry { padding-left:8px; } 2553
.git-inline-diff { margin-left:16px; }−2533
.git-command-strip { padding:4px; }−2534
.git-branch-line { font-size:.85em; gap:6px; }+2554
.git-branch-line { font-size:.85em; gap:6px; } 2555
} 2556
@container git (min-width:960px) { 2557
.git-status[data-layout="auto"] .git-workbench { --git-list-width:40%; }@@ -2619,6 +2639,7 @@
2639
.i-file{--i:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3E%3Cpath d='M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z' /%3E%3Cpath d='M14 2v5a1 1 0 0 0 1 1h5' /%3E%3C/svg%3E")} 2640
.i-folder-open{--i:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3E%3Cpath d='m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2' /%3E%3C/svg%3E")} 2641
.i-folder{--i:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3E%3Cpath d='M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z' /%3E%3C/svg%3E")}+2642
.i-git-commit-horizontal{--i:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%224%22%2F%3E%3Cpath%20d%3D%22M2%2012h6m8%200h6%22%2F%3E%3C%2Fsvg%3E")} 2643
.i-git-branch, .i-source-control{--i:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3E%3Cpath d='M15 6a9 9 0 0 0-9 9V3' /%3E%3Ccircle cx='18' cy='6' r='3' /%3E%3Ccircle cx='6' cy='18' r='3' /%3E%3C/svg%3E")} 2644
.i-globe{--i:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3E%3Ccircle cx='12' cy='12' r='10' /%3E%3Cpath d='M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20' /%3E%3Cpath d='M2 12h20' /%3E%3C/svg%3E")} 2645
.i-hard-drive{--i:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' %3E%3Cpath d='M10 16h.01' /%3E%3Cpath d='M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z' /%3E%3Cpath d='M21.946 12.013H2.054' /%3E%3Cpath d='M6 16h.01' /%3E%3C/svg%3E")}@@ -3684,6 +3705,17 @@
3705
// visible exactly once per stable row identity, without fighting manual 3706
// scrolling on unrelated renders. 3707
const gitPane = document.querySelector('.pane.focused');+3708
const composer = gitPane?.querySelector('.git-compose');+3709
const commitReview = composer?.querySelector('.git-compose-review-body');+3710
if (commitReview) {+3711
const revision = composer.dataset.scrollRevision;+3712
const previous = historyScrollState.get(composer);+3713
if (previous && previous.revision !== revision) {+3714
const page = Math.max(40, commitReview.clientHeight * .85);+3715
commitReview.scrollTop += composer.dataset.scroll === 'page-up' ? -page : page;+3716
}+3717
historyScrollState.set(composer, {revision});+3718
} 3719
// History commands own the active region; the DOM owns its pixel scroll. 3720
// Each request is consumed once, outside the memoized commit subtree. 3721
const history = gitPane?.querySelector('.git-log');packages/slate/assets/sw.jsmodified
@@ -17,7 +17,7 @@
17
// caches.match resolve them against self.location, and the fetch router keys 18
// off path SUFFIXES rather than absolute pathnames. 19
−20
const VERSION = '2026-09-08-git-askpass-1';+20
const VERSION = '2026-09-08-git-commits-1'; 21
const CACHE_NAME = `slate-shell-${VERSION}`; 22
23
// The app shell — exactly what the built index.html loads at runtime (verifiedpackages/slate/src/slate/accessory.sglmodified
@@ -242,6 +242,9 @@
242
;; (flip-thumb-side + the view switcher join in §14.2/§14.4.) 243
(define (frame-categories) 244
(list+245
#{ id: 'help label: "Help" glyph: "?" tier: 'tertiary+246
items: (list #{ id: 'key-bindings label: "Key Bindings"+247
command: "which-key-global" tier: 'primary }) } 248
#{ id: 'panes label: "Panes" glyph: "▦" tier: 'tertiary 249
items: (list 250
#{ id: 'focus label: "Focus" command: "delete-other-windows" tier: 'primary }packages/slate/src/slate/commands/git.sglmodified
@@ -2,6 +2,7 @@
2
(define-library (slate commands git) 3
(import (sigil core) (sigil string) 4
(slate command) (slate commands git branch) (slate commands git remote)+5
(slate commands git commit) 6
(slate commands project) (slate components diff) (slate git model) 7
(slate git status-view) (slate git review) (slate git diff-model) (slate git diff-view) 8
(slate git log-model) (slate git log-view)@@ -15,6 +16,7 @@
16
(set! *ops* ops) 17
(configure-git-branch-commands! ops) 18
(configure-git-remote-commands! ops)+19
(configure-git-commit-commands! ops) 20
#t) 21
(define (status-view? v) (and v (git-status-model v) #t)) 22
(define (diff-view? v) (and v (git-diff-model v) #t))packages/slate/src/slate/commands/git/commit.sgladded
@@ -0,0 +1,167 @@
+1
(define-library (slate commands git commit)+2
(import (sigil dict) (sigil string) (slate command) (slate provider) (slate state) (slate view)+3
(slate buffer) (slate undo) (slate modes editor) (slate modes git-commit)+4
(slate git model) (slate git status-view) (slate git diff-model)+5
(slate git diff-view) (slate git log-model) (slate git log-view))+6
(export configure-git-commit-commands!)+7
(begin+8
;; Rebuild the injected operation table in this module so native callers+9
;; and live commands share the same configured bindings.+10
(define *ops* #{ configured: #f })+11
(define (configure-git-commit-commands! ops) (set! *ops* (dict-merge #{ configured: #t } ops)))+12
(define (op key) (dict-ref *ops* key #f))+13
(define (data v) (dict-ref v slate/commit-data: #{}))+14
(define (repo v) (dict-ref (data v) repo: #f))+15
(define (busy? v) (memq (dict-ref v slate/commit-phase: 'ready) '(pending refreshing)))+16
(define (clear-draft v)+17
(let ((result (buffer-apply (view-buffer v) (op-set-all ""))))+18
(view-rehighlight-initial+19
(view-set-caret (view-set-buffer (view-set-undo v (make-undo)) (apply-buffer result)) 0))))+20
(define (on-composer state fn)+21
(let ((v (active-view state)))+22
(if (git-commit-view? v) (fn state v) state)))+23
(define (edit state name phase message details)+24
(update-view state name+25
(lambda (v)+26
(dict-set (dict-set (dict-set v slate/commit-phase: phase)+27
slate/commit-error: message) slate/commit-details: details))))+28
(define (request state source verb args ready fail)+29
(let ((sync? #t) (next state))+30
(define (deliver handler value)+31
(if sync? (set! next (handler next value))+32
(begin ((op set-state:) (handler ((op state:)) value)) ((op refresh:)))))+33
(source-git-request source verb args+34
(lambda (reply) (deliver ready reply)) (lambda (error) (deliver fail error)))+35
(set! sync? #f)+36
next))+37
(define (new-diff source status)+38
(make-git-diff-model source (dict-ref status repo: #{}) (dict-ref status snapshot: #{})+39
"staged" #f (lambda (ignored) ((op refresh:)))))+40
(define (open-compose state)+41
(let* ((v (active-view state))+42
(model (and v (git-status-model v)))+43
(status (and model (dict-ref (git-status-model-state model) data: #f)))+44
(key (and status (git-repository-key (dict-ref status repo: #f)))))+45
(if (not key) (with-echo state "Open Git status first")+46
(let ((name (string-append "*git-commit:" key "*")))+47
(if (find-view (views state) name)+48
(let ((focused (focus-view state name)))+49
(if (eq? (dict-ref (active-view focused) slate/commit-phase: #f) 'uncertain)+50
focused (refresh-compose focused)))+51
(let* ((diff (new-diff (view-source v) status))+52
(next (ensure-view state name+53
(lambda () (make-git-commit-view name (view-source v) status (view-name v) diff)))))+54
((op set-state:) next)+55
(git-diff-model-load! diff)+56
((op state:))))))))+57
(define (close-compose state)+58
(on-composer state+59
(lambda (state v)+60
(if (busy? v) (with-echo state "Wait for Git to finish; cancel an unlock prompt with Escape.")+61
(with-echo (focus-view state (dict-ref v slate/commit-return: "")) "Commit draft retained in this session.")))))+62
(define (refresh-compose state)+63
(on-composer state+64
(lambda (state v)+65
(if (busy? v) state+66
(let ((name (view-name v)))+67
(request (edit state name 'refreshing "" "") (dict-ref v slate/commit-source: #f) "git/status" #{ repo: (repo v) }+68
(lambda (current reply)+69
(if (and (dict? reply) (git-repository=? (repo v) (dict-ref reply repo: #f))+70
(git-snapshot-key (dict-ref reply snapshot: #f)))+71
(let* ((diff (new-diff (dict-ref v slate/commit-source: #f) reply))+72
(next (update-view (edit current name 'ready "Review the current staged changes before committing." "") name+73
(lambda (draft)+74
(dict-set (dict-set draft slate/commit-data: reply) slate/git-diff-model: diff)))))+75
(git-diff-model-invalidate! (git-diff-model v))+76
((op set-state:) next)+77
(git-diff-model-load! diff)+78
((op state:)))+79
(edit current name 'uncertain "Could not validate the refreshed review." "")))+80
(lambda (current error)+81
(edit current name 'uncertain "Could not refresh. Your draft is preserved." ""))))))))+82
(define (submit state)+83
(on-composer state+84
(lambda (state v)+85
(cond+86
((busy? v) state)+87
((eq? (dict-ref v slate/commit-phase: #f) 'uncertain)+88
(with-echo state "Inspect history and refresh the review before trying again."))+89
((string=? (string-trim (view-text v)) "")+90
(edit state (view-name v) 'ready "Write a commit message first." ""))+91
(else+92
(let* ((name (view-name v)) (repository (repo v)) (message (view-text v))+93
(models (filter+94
(lambda (m) (and m (git-repository=? repository+95
(dict-ref (dict-ref (git-status-model-state m) data: #{}) repo: #f))))+96
(map git-status-model (views state)))))+97
(if (any (lambda (m) (eq? (dict-ref (git-status-model-state m) mutation: #f) pending:)) models)+98
(with-echo state "Wait for the current Git operation")+99
(let ((locks (map git-status-model-lock! models)))+100
(define (settle reply)+101
(for-each (lambda (lock) (git-status-model-unlock! lock (and reply (dict-ref reply status: #f)))) locks))+102
(request (edit state name 'pending "" "") (dict-ref v slate/commit-source: #f) "git/commit"+103
#{ repo: repository message: message+104
expected-snapshot: (dict-ref (data v) snapshot: #{})+105
expected-branch: (dict-ref (dict-ref (data v) branch: #{}) head: "") }+106
(lambda (current reply)+107
(let ((valid? (and (dict? reply) (= (dict-ref reply version: 0) 1)+108
(git-repository=? repository (dict-ref reply repo: #f)))))+109
(settle (and valid? reply))+110
(cond+111
((not valid?) (edit current name 'uncertain "Could not verify the commit result. Inspect history before retrying." ""))+112
((eq? (dict-ref reply applied: #f) #t)+113
(for-each (lambda (view)+114
(let ((log (git-log-model view)))+115
(when (and log (git-repository=? repository (git-log-model-repo log)))+116
(git-log-model-refresh! log))))+117
(views current))+118
(let ((next (update-view current name+119
(lambda (draft)+120
(if (string=? (view-text draft) message)+121
(dict-set (clear-draft draft)+122
slate/commit-phase: 'ready)+123
(dict-set draft slate/commit-phase: 'ready))))))+124
(with-echo+125
(if (and (active-view next)+126
(string=? (view-name (active-view next)) name))+127
(focus-view next (dict-ref v slate/commit-return: "")) next)+128
"Commit created.")))+129
(else (edit current name+130
(if (dict-ref reply uncertain: #f) 'uncertain 'ready)+131
(dict-ref reply message: "Commit failed; draft preserved.")+132
(dict-ref reply details: ""))))))+133
(lambda (current error)+134
(settle #f)+135
;; No automatic resend, including reconnect. A lost+136
;; reply may follow a successful Git ref update.+137
(edit current name 'uncertain+138
(if (dict? error) (dict-ref error message: "Connection lost. Inspect history before retrying.")+139
"Connection lost. Inspect history before retrying.") "")))))))))))+140
(define (show-review review?)+141
(lambda (state)+142
(on-composer state (lambda (state v)+143
(update-view state (view-name v) (lambda (draft) (dict-set draft slate/commit-review: review?)))))))+144
(define (scroll direction)+145
(lambda (state)+146
(on-composer state+147
(lambda (state v)+148
(update-view state (view-name v)+149
(lambda (draft)+150
(dict-set (dict-set (dict-set draft slate/commit-review: #t) slate/commit-scroll: direction)+151
slate/commit-scroll-revision: (+ 1 (dict-ref draft slate/commit-scroll-revision: 0)))))))))+152
(install-builtin-command! "git-commit-menu" "Git: Commit menu" "create a staged commit" '(slate commands git commit)+153
(lambda (state)+154
(dict-set (dict-set state slate/pending-prefix: #{ "c" "git-commit-create" })+155
slate/which-key: #{ title: "Commit" map: #{ "c" "git-commit-create" } labels: #{ "c" "Create commit" } })))+156
(install-builtin-command! "git-commit-create" "Git: Compose commit" "write a staged commit message" '(slate commands git commit) open-compose)+157
(install-builtin-command! "git-commit-submit" "Git: Submit commit" "commit reviewed staged changes" '(slate commands git commit) submit)+158
(install-builtin-command! "git-commit-close" "Git: Close draft" "retain the message and return to status" '(slate commands git commit) close-compose)+159
(install-builtin-command! "git-commit-refresh" "Git: Refresh commit review" "review the current index" '(slate commands git commit) refresh-compose)+160
(install-builtin-command! "git-commit-review" "Git: Review staged" "show staged changes" '(slate commands git commit) (show-review #t))+161
(install-builtin-command! "git-commit-write" "Git: Write message" "show commit message" '(slate commands git commit) (show-review #f))+162
(install-builtin-command! "git-commit-scroll-down" "Git: Scroll staged review down" "scroll a review page" '(slate commands git commit) (scroll "page-down"))+163
(install-builtin-command! "git-commit-scroll-up" "Git: Scroll staged review up" "scroll a review page" '(slate commands git commit) (scroll "page-up"))+164
(install-builtin-command! "git-commit-more" "Git: More staged changes" "load the next diff page" '(slate commands git commit)+165
(lambda (state) (on-composer state (lambda (state v) (git-diff-model-more! (git-diff-model v)) state))))+166
(install-builtin-command! "git-commit-keep" "Git: Keep draft" "message stays in this session" '(slate commands git commit)+167
(lambda (state) (with-echo state "Commit draft retained in this session. Use C-c C-c to commit.")))))packages/slate/src/slate/commands/help.sglmodified
@@ -206,7 +206,19 @@
206
candidate-policy: live:)) 207
(else #f))) 208
+209
(define (show-bindings state title map)+210
(dict-set (dict-set (close-prompt state) slate/pending-prefix: #f)+211
slate/which-key: #{ title: title map: map }))+212
213
(define (install-help-commands!)+214
(install-builtin-command!+215
"which-key-global" "Help: Key Bindings"+216
"show the global keymap" source-module+217
(lambda (state) (show-bindings state "Global key bindings" ((op global-keymap:) state))))+218
(install-builtin-command!+219
"which-key-top" "Help: Active Key Bindings"+220
"show bindings for the current view" source-module+221
(lambda (state) (show-bindings state "Active key bindings" ((op active-keymap:) state)))) 222
(install-builtin-command! 223
"describe-key" "Help: Describe Key" 224
"explain the active binding and the binding it shadows"packages/slate/src/slate/main.sglmodified
@@ -5214,7 +5214,10 @@
5214
enclave-eval: editor-enclave-eval-command 5215
markdown-visit: editor-markdown-visit-command }) 5216
(configure-help-commands!−5217
#{ active-keymap-layers: active-keymap-layers+5217
#{ global-keymap: (lambda (state)+5218
(keymap-merge (list (user-key-overrides) (global-keymap state))))+5219
active-keymap: active-keymap-merged+5220
active-keymap-layers: active-keymap-layers 5221
faces-resolve: (lambda (state) 5222
(faces-resolve 5223
(dict-ref (active-theme state) faces: #{})packages/slate/src/slate/modes/editor.sglmodified
@@ -44,6 +44,7 @@
44
view-rehighlight view-rehighlight-window view-rehighlight-initial 45
set-defer-initial-highlight! 46
editor-overlay-sxml editor-overlay-html+47
editor-surface ; shared by derived editors, including commit drafts 48
editor-lines-html 49
view-buffer view-text view-set-buffer view-undo view-set-undo 50
view-splice-hl view-edit-hlpackages/slate/src/slate/modes/git-commit.sgladded
@@ -0,0 +1,96 @@
+1
;;; Commit messages use Slate's ordinary editor and bounded diff control.+2
(define-library (slate modes git-commit)+3
(import (sigil record) (sigil string)+4
(slate mode) (slate view) (slate buffer) (slate undo)+5
(slate modes editor) (slate syntax markdown)+6
(slate components diff) (slate git diff-model) (slate git diff-view))+7
(export make-git-commit-view git-commit-view?)+8
(begin+9
(define (button label command)+10
`(button (@ (data-slate-click ,command)) ,label))+11
(define (render-commit v)+12
(let* ((data (dict-ref v slate/commit-data: #{}))+13
(repo (dict-ref data repo: #{}))+14
(branch (dict-ref (dict-ref data branch: #{}) head: ""))+15
(phase (dict-ref v slate/commit-phase: 'ready))+16
(pending? (memq phase '(pending refreshing)))+17
(review (dict-ref v slate/commit-review: #f))+18
(error (dict-ref v slate/commit-error: ""))+19
(details (dict-ref v slate/commit-details: ""))+20
(model (git-diff-model v))+21
(diff-state (and model (dict-ref (git-diff-model-state model) state: #f))))+22
`(div (@ (class "view editor git-compose")+23
(data-review ,(if review "true" "false"))+24
(data-scroll ,(dict-ref v slate/commit-scroll: ""))+25
(data-scroll-revision ,(number->string (dict-ref v slate/commit-scroll-revision: 0))))+26
(header (@ (class "git-compose-header"))+27
(div (@ (class "git-compose-title"))+28
(strong "Create commit")+29
(span ,branch)+30
(span (@ (class "git-compose-path") (title ,(dict-ref repo worktree: "")))+31
,(dict-ref repo worktree: "")))+32
(div (@ (class "git-compose-actions"))+33
,(button "Write" "git-commit-write")+34
,(button "Review staged" "git-commit-review")+35
,(button "Refresh review" "git-commit-refresh")))+36
,@(if (string=? error "") '()+37
(list `(div (@ (class "git-compose-error") (role "status"))+38
,error+39
,@(if (string=? details "") '()+40
(list `(details (summary "Git / hook output") (pre ,details)))))))+41
(div (@ (class "git-compose-workbench"))+42
(section (@ (class "git-compose-message") (aria-label "Commit message")+43
,@(if pending? '((inert "")) '()))+44
(div (@ (class "git-compose-label")) "Message"+45
(span "Summary, blank line, then details. Markdown is welcome."))+46
,(editor-surface v))+47
(aside (@ (class "git-compose-review") (aria-label "Staged changes"))+48
(div (@ (class "git-compose-label")) "Staged changes")+49
(div (@ (class "git-compose-review-body") (tabindex "0"))+50
,@(cond ((eq? diff-state loading:) (list `(p "Loading staged changes…")))+51
((eq? diff-state error:) (list `(p "Review changed or could not load. Refresh review.")))+52
(else '()))+53
,(diff-control-render (git-diff-rows v) #f+54
(lambda (row)+55
(if (eq? (dict-ref row kind: #f) continuation:)+56
"git-commit-more" "git-commit-review"))+57
revision: (and model+58
(list (git-diff-model-generation model)+59
(dict-ref (git-diff-model-state model) next-cursor: #f)+60
(dict-ref (git-diff-model-state model) complete: #f)+61
(length (dict-ref (git-diff-model-state model) pages: '()))))))))+62
(footer (@ (class "git-compose-footer"))+63
,(button "Close" "git-commit-close")+64
(span ,(cond ((eq? phase 'pending) "Running Git hooks / signing…")+65
((eq? phase 'refreshing) "Refreshing staged review…")+66
((eq? phase 'uncertain) "Inspect history, then refresh review before retrying.")+67
(else "Commits staged changes. Draft retained until success.")))+68
(button (@ (class "git-compose-submit")+69
(data-slate-click "git-commit-submit")+70
,@(if (or pending? (eq? phase 'uncertain)) '((disabled "")) '()))+71
"Commit")))))+72
(define-mode git-commit-mode derives: editor+73
glyph: "●" icon: "git-commit-horizontal" lighter: "Commit"+74
syntax: markdown-syntax prose: #t comment: ""+75
keymap: #{ "C-c" #{ "C-c" "git-commit-submit" "C-k" "git-commit-close"+76
"r" "git-commit-review" "w" "git-commit-write"+77
"n" "git-commit-scroll-down" "p" "git-commit-scroll-up" }+78
"C-s" "git-commit-keep" }+79
render: render-commit+80
actions: (list #{ id: 'git label: "Git" glyph: "±" tier: 'primary+81
items: (list #{ id: 'submit label: "Commit" command: "git-commit-submit" tier: 'primary }+82
#{ id: 'review label: "Review" command: "git-commit-review" tier: 'primary }+83
#{ id: 'write label: "Write" command: "git-commit-write" tier: 'primary }+84
#{ id: 'refresh label: "Refresh review" command: "git-commit-refresh" tier: 'secondary }+85
#{ id: 'close label: "Close" command: "git-commit-close" tier: 'secondary }) })+86
commands: (lambda (v)+87
(list (make-cand "Git: Create commit" "C-c C-c" "git-commit-submit")+88
(make-cand "Git: Refresh commit review" "" "git-commit-refresh")+89
(make-cand "Git: Close commit draft" "C-c C-k" "git-commit-close"))))+90
(define (git-commit-view? v) (and v (git-commit-mode? v)))+91
(define (make-git-commit-view name source data return-view model)+92
(view-rehighlight-initial+93
(git-commit-mode slate/name: name slate/commit-source: source+94
slate/buffer: (make-buffer "") slate/undo: (make-undo) slate/hl-cache: (vector)+95
slate/result: "" slate/commit-data: data slate/commit-return: return-view+96
slate/git-diff-model: model slate/commit-phase: 'ready)))))packages/slate/src/slate/modes/git-log.sglmodified
@@ -8,8 +8,6 @@
8
(begin 9
(define (button label command) 10
`(button (@ (data-slate-click ,command)) ,label))−11
(define (hint key label command)−12
`(button (@ (data-slate-click ,command)) (kbd ,key) ,label)) 11
(define (x lane) (number->string (+ 9 (* lane 14)))) 12
(define (graph row) 13
;; The current DOM bridge creates HTML elements only. Use a themed SVG@@ -51,8 +49,8 @@
49
`(div (@ (class "git-log-empty")) 50
(span (@ (class "icon i-git-commit-horizontal"))) 51
(strong "Explore this history")−54
(span "Select a commit, then press Enter to inspect it.")−55
,(hint "RET" "Inspect commit" "git-log-inspect")))+52
(span "Select a commit to inspect it.")+53
,(button "Inspect commit" "git-log-inspect"))) 54
((eq? (dict-ref review state: #f) loading:) 55
`(div (@ (class "git-log-empty") (role "status")) "Loading commit…")) 56
((eq? (dict-ref review state: #f) error:)@@ -105,8 +103,6 @@
103
(div (div (@ (class "git-eyebrow")) "HISTORY") 104
(h1 ,(dict-ref (dict-ref state branch: #{}) head: "Repository")) 105
(div (@ (class "git-log-path")) ,(dict-ref repo worktree: "")))−108
(div (@ (class "git-header-actions"))−109
,(button "Refresh" "git-log-refresh") ,(button "Close" "git-log-quit")) 106
(div (@ (class "git-layout-control") (role "group") (aria-label "History layout")) 107
,@(map (lambda (pair) 108
`(button (@ (data-slate-click ,(string-append "git-layout:" (car pair)))@@ -115,8 +111,7 @@
111
(div (@ (class "git-log-workbench")) 112
(section (@ (class "git-log-list") (aria-label "Commits")) 113
(div (@ (class "git-log-pane-label"))−118
,(hint "ESC" "Commits" "git-log-focus-list")−119
(span ,(if review-focused? "" "n / p move")))+114
,(button "Commits" "git-log-focus-list")) 115
(div (@ (class "git-log-pagination")) 116
(span ,(cond ((eq? kind loading:) "Loading history…") 117
((eq? kind error:) "History unavailable or changed. Refresh to retry.")@@ -129,18 +124,10 @@
124
(git-log-graph commits))) 125
(section (@ (class "git-log-review") (aria-label "Commit review")) 126
(div (@ (class "git-log-pane-label"))−132
,(hint "TAB" "Review" "git-log-switch-pane")−133
(span ,(if review-focused? "n / p scroll · PgUp / PgDn" "Tab to enter")))+127
,(button "Review" "git-log-switch-pane")) 128
(div (@ (class "git-log-review-body") (tabindex "-1")) 129
,(web-client-memo "history-review" (number->string (git-log-model-revision model))−136
(lambda () (review-content (dict-ref state review: #f)))))))−137
(footer (@ (class "git-command-strip"))−138
,(hint "n / p" (if review-focused? "Scroll" "Move") "git-log-next")−139
,(hint "TAB" (if review-focused? "Back to commits" "Enter review") "git-log-switch-pane")−140
,(hint "w" "Copy ID" "git-log-copy")−141
,(hint "b" "Branches" "git-branch-menu")−142
,(hint "g" "Refresh" "git-log-refresh")−143
,(hint "?" "Commands" "git-help")))))+130
(lambda () (review-content (dict-ref state review: #f)))))))))) 131
(define-mode git-log-mode 132
glyph: "◉" icon: "history" lighter: "History" 133
info: (lambda (v) (string-append "History · "@@ -170,6 +157,11 @@
157
items: (list #{ id: 'up label: "↑" key: "ArrowUp" tier: 'primary repeat: #t } 158
#{ id: 'down label: "↓" key: "ArrowDown" tier: 'primary repeat: #t } 159
#{ id: 'inspect label: "Inspect" command: "git-log-inspect" tier: 'primary }+160
#{ id: 'review label: "Review" command: "git-log-switch-pane" tier: 'primary }+161
#{ id: 'branches label: "Branches" command: "git-branch-menu" tier: 'secondary }+162
#{ id: 'copy label: "Copy ID" command: "git-log-copy" tier: 'secondary }+163
#{ id: 'refresh label: "Refresh" command: "git-log-refresh" tier: 'secondary }+164
#{ id: 'newer label: "Newer" command: "git-log-newer" tier: 'secondary } 165
#{ id: 'more label: "Older" command: "git-log-older" tier: 'secondary } 166
#{ id: 'quit label: "Close" command: "git-log-quit" tier: 'tertiary }) }) 167
render: git-log-render)))packages/slate/src/slate/modes/git-status.sglmodified
@@ -252,33 +252,6 @@
252
'(("auto" . "Follow the Git pane width") 253
("inline" . "Review diffs in the status outline") 254
("split" . "Review beside the status outline"))))))−255
(define (command-hint key label command)−256
`(button (@ (data-slate-click ,command)) (kbd ,key) ,label))−257
(define (command-strip row)−258
(let* ((kind (and row (dict-ref row kind: #f)))−259
(hunk (and row (dict-ref row hunk-id: #f)))−260
(section (and row (dict-ref row section: #f)))−261
(staged? (or (eq? section staged:)−262
(and hunk (string=? (dict-ref hunk side: "") "staged"))))−263
(work? (or (eq? section unstaged:) (eq? section untracked:)−264
(and hunk (string=? (dict-ref hunk side: "") "worktree")))))−265
`(nav (@ (class "git-command-strip") (aria-label "Git commands"))−266
,(command-hint "n / p" "Move" "git-next")−267
,(command-hint "Tab" "Fold" "git-toggle")−268
,(command-hint "↵" "Open" "git-visit")−269
,@(cond (staged? (list (command-hint "u" "Unstage" "git-unstage")))−270
(work? (list (command-hint "s" "Stage" "git-stage")))−271
(else '()))−272
,@(if (and work? (not (eq? kind section:)))−273
(list (command-hint "k" "Discard" "git-discard")) '())−274
,(command-hint "g" "Refresh" "git-refresh")−275
,(command-hint "?" "Commands" "git-help")−276
(span (@ (class "git-selection-scope"))−277
,(cond (hunk "Hunk selected")−278
((eq? kind entry:) "File selected")−279
((eq? kind section:) "Section selected")−280
((eq? kind commit:) "Commit selected")−281
(else "")))))) 255
(define (render-ready v data) 256
(let* ((repo (dict-ref data repo: #{})) 257
(branch (dict-ref data branch: #{}))@@ -306,12 +279,7 @@
279
,(string-append 280
(value-text (or (dict-ref source host: #f) 281
(dict-ref source provider: #f)))−309
" / " (value-text (dict-ref repo root: #f))))−310
,(icon-button "git-branch" "Branches (b)" "git-branch-menu" "git-header-icon")−311
,(icon-button "download" "Fetch (f)" "git-fetch" "git-header-icon")−312
,(icon-button "history" "History (l)" "git-log" "git-header-icon")−313
,(icon-button "history" "Refresh Git status" "git-refresh" "git-header-icon")−314
,(icon-button "x" "Close Git status" "git-quit" "git-header-icon"))+282
" / " (value-text (dict-ref repo root: #f))))) 283
(div (@ (class "git-branch-line")) 284
(span (@ (class "icon i-git-branch") (aria-hidden "true"))) 285
(span (@ (class "git-branch-name"))@@ -348,10 +316,9 @@
316
(span (@ (class "icon i-code") (aria-hidden "true"))) 317
(strong ,(if entry-row "Ready to review" "Select a change")) 318
(p ,(if entry-row "Expand this file to review its diff."−351
"Use n and p to move through files and hunks."))+319
"Select a file to review its changes.")) 320
,@(if entry-row−353
(list (command-hint "Tab" "Open diff" "git-toggle")) '()))))))−354
,(command-strip row))))+321
(list `(button (@ (data-slate-click "git-toggle")) "Open diff")) '()))))))))) 322
323
(define (git-status-render v) 324
(let* ((state (model-state v)) (kind (dict-ref state state: loading:))@@ -365,7 +332,7 @@
332
333
(define-mode git-status-mode 334
glyph: "±" icon: "source-control" lighter: "Git" info: git-status-info−368
keymap: #{ "b" "git-branch-menu" "f" "git-fetch" "n" "git-next" "ArrowDown" "git-next"+335
keymap: #{ "c" "git-commit-menu" "b" "git-branch-menu" "f" "git-fetch" "n" "git-next" "ArrowDown" "git-next" 336
"p" "git-prev" "ArrowUp" "git-prev" 337
"Tab" "git-toggle" "Enter" "git-visit" 338
"s" "git-stage" "u" "git-unstage" "k" "git-discard"@@ -373,6 +340,7 @@
340
commands: (lambda (v) 341
(list (make-cand "Git: Branches" "b" "git-branch-menu") 342
(make-cand "Git: Fetch" "f" "git-fetch")+343
(make-cand "Git: Commit" "c c" "git-commit-create") 344
(make-cand "Git: Cycle layout" "v" "git-layout") 345
(make-cand "Git: Refresh" "g" "git-refresh") 346
(make-cand "Git: Toggle" "TAB" "git-toggle")@@ -390,6 +358,7 @@
358
#{ id: 'visit label: "Open" command: "git-visit" tier: 'primary } 359
#{ id: 'branches label: "Branches" command: "git-branch-menu" tier: 'primary } 360
#{ id: 'fetch label: "Fetch" command: "git-fetch" tier: 'primary }+361
#{ id: 'commit label: "Commit" command: "git-commit-create" tier: 'primary } 362
#{ id: 'history label: "History" command: "git-log" tier: 'primary } 363
#{ id: 'diff label: "Diff" command: "git-diff" tier: 'primary } 364
#{ id: 'stage label: "Stage" command: "git-stage" tier: 'primary }packages/slate/test/git-auth-fixture.mjsmodified
@@ -13,7 +13,7 @@ import {configureSharedGPGFixture} from './shared-gpg-fixture.mjs';
13
export const username='slate-test-user'; 14
export const password='slate-real-test-password'; 15
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));−16
export async function createGitAuthFixture({transport='http',gpg=false,sharedGpg=false}={}) {+16
export async function createGitAuthFixture({transport='http',gpg=false,sharedGpg=false,signing=false}={}) { 17
const workspace=path.resolve(import.meta.dirname,'../../..'); 18
fs.mkdirSync(path.join(workspace,'.sigil'),{recursive:true}); 19
const root=fs.mkdtempSync(gpg?'/tmp/slate-gpg-':path.join(workspace,'.sigil/git-auth-'));@@ -71,7 +71,17 @@ export async function createGitAuthFixture({transport='http',gpg=false,sharedGpg
71
await new Promise(resolve=>httpServer.listen(0,'127.0.0.1',resolve)); 72
let ssh=null,agent=null,remote; 73
try {−74
if(sharedGpg)agent=configureSharedGPGFixture(path.join(workspace,'build/dev/bin/slate'),password);+74
if(sharedGpg || signing)agent=configureSharedGPGFixture(path.join(workspace,'build/dev/bin/slate'),password);+75
if(signing){+76
environment.GNUPGHOME=agent.home;+77
const keys=spawnSync('gpg',['--batch','--with-colons','--list-secret-keys'],{env:environment,encoding:'utf8'});+78
assert.equal(keys.status,0,keys.stderr);+79
const fingerprint=keys.stdout.split('\n').find(line=>line.startsWith('fpr:')).split(':')[9];+80
const added=spawnSync('gpg',['--batch','--pinentry-mode','loopback','--passphrase-fd','0',+81
'--quick-add-key',fingerprint,'ed25519','sign','0'],{env:environment,input:password+'\n',encoding:'utf8'});+82
assert.equal(added.status,0,added.stderr);+83
git(repo,['config','user.signingkey',fingerprint]);+84
} 85
ssh=transport==='ssh'?await configureSSHFixture(root,bare,password,agent):null; 86
remote=ssh?ssh.remote:'http://127.0.0.1:'+httpServer.address().port+'/alias'; 87
if(ssh)Object.assign(environment,ssh.environment);@@ -101,7 +111,7 @@ export async function createGitAuthFixture({transport='http',gpg=false,sharedGpg
111
await new Promise(resolve=>portProbe.listen(0,'127.0.0.1',resolve)); 112
const nodePort=portProbe.address().port; 113
await new Promise(resolve=>portProbe.close(resolve));−104
let appDir=path.join(workspace,'build/web');+114
let appDir=process.env.A3_APP_DIR || path.join(workspace,'build/web'); 115
if(ssh && process.env.SLATE_TEST_MANUAL==='1'){ 116
appDir=path.join(root,'app');fs.mkdirSync(appDir); 117
for(const name of fs.readdirSync(path.join(workspace,'build/web'))){packages/slate/test/git-branch-browser-gate.mjsmodified
@@ -40,7 +40,11 @@ export async function branchGate({tab,send,sgl,evalIn,waitFor,key,sentFrames,run
40
assert.equal(count('git/switch-branch'),0,'cancelling a branch prompt mutated Git'); 41
42
// The visible menu's touch/pointer route uses the same armed key map.−43
await click('.git-status [data-slate-click="git-branch-menu"]');+43
await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},tab.sessionId);+44
if(!await evalIn(tab,`!!document.querySelector('.accessory-bar [data-slate-click="git-branch-menu"]')`))+45
await click('[data-slate-click="acc-cat:git"]');+46
await waitFor(tab,`!!document.querySelector('.accessory-bar [data-slate-click="git-branch-menu"]')`,'mobile Git actions');+47
await click('.accessory-bar [data-slate-click="git-branch-menu"]'); 48
await click('.which-key-panel [data-slate-key="c"]'); 49
await waitFor(tab,label('Create and switch'),'create branch prompt'); 50
await type('feature/browser'); await key('Enter');@@ -60,6 +64,7 @@ export async function branchGate({tab,send,sgl,evalIn,waitFor,key,sentFrames,run
64
await waitFor(tab,`document.querySelector('.git-branch-name')?.textContent==='main'`,'switch back to main'); 65
assert.equal(branch(),'main'); 66
+67
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false},tab.sessionId); 68
await key('l'); 69
await waitFor(tab,`document.querySelectorAll('.git-log-row').length===50`,'history for branching'); 70
await key('n'); await key('Enter');@@ -89,7 +94,9 @@ export async function branchGate({tab,send,sgl,evalIn,waitFor,key,sentFrames,run
94
assert.ok(sentFrames.filter(f=>/git\/(branches|switch-branch|create-branch)/.test(f)).every(f=>f.includes(gateRoot)),'branch operations lost root identity'); 95
96
await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},tab.sessionId);−92
await click('.git-status [data-slate-click="git-branch-menu"]');+97
if(!await evalIn(tab,`!!document.querySelector('.accessory-bar [data-slate-click="git-branch-menu"]')`))+98
await click('[data-slate-click="acc-cat:git"]');+99
await click('.accessory-bar [data-slate-click="git-branch-menu"]'); 100
await waitFor(tab,`!!document.querySelector('.which-key-panel button.wk-row')`,'mobile branch menu'); 101
assert.ok(await evalIn(tab,`(()=>{const p=document.querySelector('.which-key-panel');return p.scrollWidth<=p.clientWidth})()`),'branch menu overflows mobile'); 102
await screenshot('mobile');packages/slate/test/git-commit-browser-gate.mjsadded
@@ -0,0 +1,178 @@
+1
// Real staged commits, hook failure recovery, responsive UI, and GPG signing.+2
import assert from 'node:assert/strict';+3
import fs from 'node:fs';+4
import os from 'node:os';+5
import path from 'node:path';+6
import { spawn } from 'node:child_process';+7
import {createGitAuthFixture,password} from './git-auth-fixture.mjs';+8
+9
const fixture=await createGitAuthFixture({signing:true});+10
const git=args=>fixture.git(fixture.repo,args);+11
git(['config','user.name','Slate Commit Fixture']);+12
git(['config','user.email','fixture@example.invalid']);+13
git(['config','commit.gpgsign','false']);+14
fs.writeFileSync(path.join(fixture.repo,'README.md'),'Staged commit content\n');+15
git(['add','README.md']);+16
fs.appendFileSync(path.join(fixture.repo,'README.md'),'Unstaged content stays out\n');+17
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));+18
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-auth-chrome-'));+19
const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu',+20
'--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'],+21
{ stdio: ['ignore', 'ignore', 'pipe'] });+22
const chromeExit = new Promise(resolve => chrome.once('exit', resolve));+23
let socket;+24
const errors = [];+25
try {+26
const activePort = path.join(profile, 'DevToolsActivePort');+27
let debugPort,browserPath;+28
for (let i=0;i<300;i++){+29
if(fs.existsSync(activePort)){+30
[debugPort,browserPath]=fs.readFileSync(activePort,'utf8').trim().split('\n');+31
if(/^\d+$/.test(debugPort) && browserPath?.startsWith('/devtools/browser/'))break;+32
}+33
await sleep(50);+34
}+35
assert.ok(browserPath?.startsWith('/devtools/browser/'),'Chrome did not publish its endpoint');+36
socket = new WebSocket(`ws://127.0.0.1:${debugPort}${browserPath}`);+37
await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });+38
let nextId = 0;+39
const pending = new Map();+40
socket.onmessage = event => {+41
const message = JSON.parse(event.data);+42
if(message.method==='Runtime.consoleAPICalled') { const text=message.params.args.map(a=>a.value??a.description).join(' '); if(/Error:|unbound/.test(text))errors.push(text); }+43
if (message.method === 'Runtime.exceptionThrown')+44
errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text);+45
if (message.id && pending.has(message.id)) {+46
const pair = pending.get(message.id); pending.delete(message.id);+47
message.error ? pair.reject(new Error(message.error.message)) : pair.resolve(message.result);+48
}+49
};+50
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {+51
const id = ++nextId;+52
const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 15000);+53
pending.set(id, {resolve: value => { clearTimeout(timer); resolve(value); },+54
reject: error => { clearTimeout(timer); reject(error); }});+55
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));+56
});+57
const target = await send('Target.createTarget', { url: 'about:blank' });+58
const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true });+59
const session = attached.sessionId;+60
await send('Runtime.enable', {}, session);+61
await send('Emulation.setDeviceMetricsOverride', {width:1280,height:800,deviceScaleFactor:1,mobile:false}, session);+62
await send('Page.navigate', {+63
url: fixture.url+'?rbdebug'+64
}, session);+65
const evaluate = async expression => {+66
const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, session);+67
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);+68
return result.result.value;+69
};+70
async function waitFor(expression, label) {+71
for (let i = 0; i < 360; i++) {+72
if (await evaluate(expression)) return;+73
if(errors.length)throw new Error(`Browser error while waiting for ${label}: ${errors.join(" | ")}`);+74
await sleep(100);+75
}+76
const diagnostic = await evaluate(`({status:document.getElementById('status')?.textContent, body:document.body.innerText.slice(-2000)})`);+77
throw new Error(`timeout waiting for ${label}; errors=${errors.join(' | ')}; page=${JSON.stringify(diagnostic)}`);+78
}+79
+80
+81
const sgl = form => evaluate(`window.slateEval(${JSON.stringify(form)})`);+82
const dispatch = command => sgl(`(slate-dispatch ${JSON.stringify(command)})`);+83
const key = async (key,modifiers=0) => {+84
await send('Input.dispatchKeyEvent',{type:'keyDown',key,modifiers},session);+85
await send('Input.dispatchKeyEvent',{type:'keyUp',key,modifiers},session);+86
};+87
const click = async selector => {+88
await waitFor(`!!document.querySelector(${JSON.stringify(selector)})`,selector);+89
await evaluate(`new Promise(resolve=>requestAnimationFrame(()=>resolve(true)))`);+90
const point=await evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw new Error('Missing '+${JSON.stringify(selector)});e.scrollIntoView({block:'nearest'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);+91
await send('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',clickCount:1,...point},session);+92
await send('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',clickCount:1,...point},session);+93
};+94
const screenshot=async name=>{+95
if(!process.env.GIT_COMMIT_SCREENSHOTS)return;+96
fs.mkdirSync(process.env.GIT_COMMIT_SCREENSHOTS,{recursive:true});+97
const shot=await send('Page.captureScreenshot',{format:'png'},session);+98
fs.writeFileSync(path.join(process.env.GIT_COMMIT_SCREENSHOTS,name+'.png'),Buffer.from(shot.data,'base64'));+99
};+100
await waitFor("document.getElementById('status')?.textContent==='ready'",'boot');+101
await waitFor("window.slateEval('(slate-node-connected?)')==='1'",'node connection');+102
await sgl(`(begin (import (slate root)) (register-root! commitfixture: #{ provider: local: home: ${JSON.stringify(fixture.repo)} host: "localhost" class: machine: }))`);+103
await dispatch('openroot:commitfixture');+104
await waitFor("!!document.querySelector('.folder')",'repository root');+105
await dispatch('git-status');+106
await waitFor("!!document.querySelector('.git-staged')",'staged status');+107
assert.equal(await evaluate("document.querySelectorAll('.git-repository-header [data-slate-click]:not([data-slate-click^=\"git-layout:\"]),.git-command-strip').length"),0);+108
await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},session);+109
await click('[data-slate-click="acc-cat:help"]');+110
await click('.accessory-bar [data-slate-click="which-key-global"]');+111
await waitFor("document.querySelector('.which-key-panel')?.textContent.includes('Global key bindings')",'global help menu');+112
await key('Escape');+113
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false},session);+114
await key('c'); await key('c');+115
await waitFor("!!document.querySelector('.git-compose .editor-content')",'ordinary editor composer');+116
await waitFor("document.querySelector('.git-compose-review')?.textContent.includes('Staged commit content')",'staged review');+117
assert.equal(await evaluate("!!document.querySelector('.auth-prompt')?.open"),false,'composer prompted before submission');+118
await click('.git-compose .editor-content');+119
const message='Create the initial commit\n\n# Markdown heading\n\nKeep **staged content** only.\n';+120
await send('Input.insertText',{text:message},session);+121
await dispatch('git-commit-keep');+122
await waitFor("document.querySelector('.git-compose .editor-content')?.textContent.includes('Markdown heading')",'typing survives rerender');+123
await screenshot('wide');+124
await key('c',2); await key('k',2);+125
await waitFor("!!document.querySelector('.git-status')",'close draft shortcut');+126
await key('c');await key('c');+127
await waitFor("document.querySelector('.git-compose .editor-content')?.textContent.includes('Markdown heading')",'retained draft');+128
await waitFor("!document.querySelector('.git-compose-message')?.inert",'refreshed draft');+129
const hook=path.join(fixture.repo,'.git/hooks/pre-commit');+130
fs.writeFileSync(hook,'#!/bin/sh\necho "Fixture formatting check failed" >&2\nexit 1\n',{mode:0o700});+131
await key('c',2);await key('c',2);+132
await waitFor("document.querySelector('.git-compose-error')?.textContent.includes('Fixture formatting check failed')",'hook failure output');+133
assert.ok(await evaluate("document.querySelector('.git-compose .editor-content').textContent.includes('Markdown heading')"));+134
await screenshot('hook-failure');+135
fs.unlinkSync(hook);+136
await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},session);+137
await click('[data-slate-click="acc-cat:git"]');+138
await click('.accessory-bar [data-slate-click="git-commit-review"]');+139
await waitFor("document.querySelector('.git-compose').dataset.review==='true'",'mobile staged review');+140
assert.ok(await evaluate("getComputedStyle(document.querySelector('.git-compose-message')).display==='none'"));+141
assert.ok(await evaluate("document.querySelector('.git-compose').scrollWidth<=document.querySelector('.git-compose').clientWidth"),'mobile composer overflows');+142
await screenshot('mobile-review');+143
await click('.accessory-bar [data-slate-click="git-commit-write"]');+144
await screenshot('mobile-write');+145
await click('.accessory-bar [data-slate-click="git-commit-submit"]');+146
await waitFor("!!document.querySelector('.git-status')",'initial commit success');+147
assert.equal(git(['show','HEAD:README.md']),'Staged commit content');+148
assert.equal(git(['log','-1','--format=%B']),message.trim());+149
assert.ok(git(['diff']).includes('Unstaged content stays out'));+150
+151
git(['add','README.md']);git(['config','commit.gpgsign','true']);+152
await dispatch('git-refresh');+153
await waitFor("!!document.querySelector('.git-staged')",'new staged content');+154
await dispatch('git-commit-create');+155
await waitFor("!!document.querySelector('.git-compose .editor-content') && !document.querySelector('.git-compose-message')?.inert",'signing composer');+156
await dispatch('git-commit-write');+157
await click('.git-compose .editor-content');+158
await send('Input.insertText',{text:'Sign this commit\n'},session);+159
await dispatch('git-commit-submit');+160
await dispatch('git-commit-submit');+161
await waitFor("document.querySelector('.auth-prompt')?.open",'real signing prompt');+162
await screenshot('signing');+163
await click('.auth-prompt input');+164
await send('Input.insertText',{text:password},session);await key('Enter');+165
await waitFor("!!document.querySelector('.git-status')",'signed commit success');+166
assert.equal(git(['rev-list','--count','HEAD']),'2','duplicate submission created another commit');+167
assert.ok(git(['cat-file','-p','HEAD']).includes('gpgsig -----BEGIN PGP SIGNATURE-----'));+168
git(['verify-commit','HEAD']);+169
assert.equal(fixture.agent.fallbackCalls(),'','signing escaped to fallback pinentry');+170
assert.deepEqual(errors,[]);+171
console.log(JSON.stringify({verdict:'PASS',initialCommit:true,stagedOnly:true,markdownMessage:true,hookFailure:true,draftRetained:true,mobileMenus:true,globalHelp:true,signedCommit:true,duplicatePrevented:true}));+172
} finally {+173
if(socket)socket.close();chrome.kill('SIGTERM');+174
await Promise.race([chromeExit,sleep(2000)]);+175
if(process.env.GIT_GATE_LOG)fs.writeFileSync(process.env.GIT_GATE_LOG,fixture.logs());+176
await fixture.close();+177
fs.rmSync(profile,{recursive:true,force:true,maxRetries:5,retryDelay:100});+178
}packages/slate/test/git-commit-command-test.sgladded
@@ -0,0 +1,82 @@
+1
(import (sigil test) (slate command) (slate commands git)+2
(slate buffer) (slate modes editor) (slate modes git-commit)+3
(slate modes git-status) (slate git model)+4
(slate modified) (slate pane) (slate provider) (slate root) (slate space) (slate state) (slate view))+5
(define STATE #{})+6
(define HELD #f)+7
(define COUNT 0)+8
(define REPO #{ root: "commit-test" worktree: "/repo" common-dir: "/repo/.git" })+9
(define SNAP #{ head-oid: "old" index-id: "index" worktree-id: "work" })+10
(define DATA #{ version: 1 repo: REPO snapshot: SNAP branch: #{ head: "main" }+11
entries: '() recent-commits: '() })+12
(register-provider! commit-test:+13
#{ git-request:+14
(lambda (source verb args ready fail)+15
(cond ((string=? verb "git/discover") (ready #{ version: 1 node-repo: REPO }))+16
((string=? verb "git/status") (ready DATA))+17
((string=? verb "git/diff")+18
(ready #{ version: 1 repo: REPO snapshot: SNAP files: '() complete: #t payload-bytes: 0 }))+19
((string=? verb "git/commit")+20
(set! COUNT (+ COUNT 1))+21
(set! HELD (list args ready fail)))))+22
subscribe: (lambda (source callback) (lambda () #t)) })+23
(register-root! commit-test: #{ provider: commit-test: host: "test" class: machine: })+24
(define (run command) (set! STATE (command-run STATE command)))+25
(define (setup!)+26
(set! HELD #f)+27
(set! COUNT 0)+28
(set! STATE #{ slate/views: (list (view slate/name: "plain"+29
slate/source: (root->source commit-test: file: "/repo/file")))+30
slate/spaces: (list (make-space "main" (make-leaf 0 "plain") 0 1))+31
slate/active-space: "main" })+32
(configure-git-commands!+33
#{ state: (lambda () STATE) set-state: (lambda (state) (set! STATE state))+34
refresh: (lambda () #t) })+35
(run "git-status")+36
(run "git-commit-create")+37
(set! STATE (update-active STATE+38
(lambda (v)+39
(view-set-buffer v (apply-buffer (buffer-apply (view-buffer v) (op-set-all "Keep this draft\n"))))))))+40
+41
(test "commit draft is an ordinary editor and survives closing"+42
(setup!)+43
(assert-true (editor? (active-view STATE)))+44
(assert-true (git-commit-view? (active-view STATE)))+45
(assert-false (view-saveable? (active-view STATE)))+46
(assert-true (pair? (view-render (active-view STATE))))+47
(run "git-commit-close")+48
(run "git-commit-create")+49
(assert-equal "Keep this draft\n" (view-text (active-view STATE))))+50
+51
(test "pending commit cannot be submitted twice and hook failure preserves text"+52
(setup!)+53
(run "git-commit-submit")+54
(run "git-commit-submit")+55
(assert-equal 1 COUNT)+56
(assert-equal SNAP (dict-ref (car HELD) expected-snapshot: #f))+57
((cadr HELD) #{ version: 1 repo: REPO applied: #f uncertain: #f status: DATA+58
message: "Hook failed" details: "Fix formatting" })+59
(assert-equal "Keep this draft\n" (view-text (active-view STATE)))+60
(assert-equal "Fix formatting" (dict-ref (active-view STATE) slate/commit-details: #f)))+61
+62
(test "lost commit reply requires explicit review and never automatically resends"+63
(setup!)+64
(run "git-commit-submit")+65
((caddr HELD) #{ message: "Disconnected" })+66
(run "git-commit-submit")+67
(run "git-commit-close")+68
(run "git-commit-create")+69
(run "git-commit-submit")+70
(assert-equal 1 COUNT)+71
(assert-equal "Keep this draft\n" (view-text (active-view STATE)))+72
(run "git-commit-refresh")+73
(run "git-commit-submit")+74
(assert-equal 2 COUNT))+75
+76
(test "successful commit returns to status and clears only the submitted draft"+77
(setup!)+78
(let ((name (view-name (active-view STATE))))+79
(run "git-commit-submit")+80
((cadr HELD) #{ version: 1 repo: REPO applied: #t status: DATA oid: "new" })+81
(assert-false (git-commit-view? (active-view STATE)))+82
(assert-equal "" (view-text (find-view (views STATE) name)))))packages/slate/test/git-status-browser-gate.mjsmodified
@@ -205,7 +205,7 @@ try {
205
if (await evalIn(tab, expression)) return; 206
await sleep(100); 207
}−208
const body = await evalIn(tab, 'document.body.innerText.slice(0, 1600)');+208
const body = await evalIn(tab, `JSON.stringify({body:document.body.innerText.slice(0,1600),pane:document.querySelector('.pane.focused')?.getBoundingClientRect(),selected:document.querySelector('.git-row.selected')?.getBoundingClientRect(),viewport:{w:innerWidth,h:innerHeight}})`); 209
throw new Error(`timeout waiting for ${label}; runtime=${runtimeMessages.join(' | ')}; daemon=${daemonLog.slice(-3000)}; body=${body}`); 210
}; 211
const boot = async tab => {@@ -438,12 +438,16 @@ try {
438
}; 439
const beforeRefresh = sentFrames.filter(frame => frame.includes('git/status')).length; 440
fs.rmSync(releaseFile);−441
await click('.git-status .git-header-icon[title^="Refresh"]');+441
await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},tab.sessionId);+442
await click('[data-slate-click="acc-cat:git"]');+443
await waitFor(tab,`!!document.querySelector('.accessory-bar [data-slate-click="git-refresh"]')`,'mobile Git actions');+444
await click('.accessory-bar [data-slate-click="git-refresh"]'); 445
await waitFor(tab, `document.querySelector('.mode-line')?.textContent.includes('refreshing')`, 'pointer refresh pending'); 446
fs.writeFileSync(releaseFile, 'release\n'); 447
await waitFor(tab, `!document.querySelector('.mode-line')?.textContent.includes('refreshing')`, 'pointer refresh complete'); 448
assert.ok(sentFrames.filter(frame => frame.includes('git/status')).length > beforeRefresh, 449
'pointer refresh did not send git/status');+450
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false},tab.sessionId); 451
await click('.git-entry .git-entry-path'); 452
await waitFor(tab, `!document.querySelector('.git-status') && document.querySelector('.editor-input, .editor-content')`, 453
'pointer filename visit', 100);@@ -455,7 +459,7 @@ try {
459
for (let i = 0; i < 35; i++) await key('n'); 460
await waitFor(tab, `(() => { const pane=document.querySelector('.pane.focused'); const row=document.querySelector('.git-row.selected'); 461
if (!pane || !row) return false; const vr=pane.getBoundingClientRect(), rr=row.getBoundingClientRect();−458
return rr.top >= vr.top && rr.bottom <= vr.bottom; })()`, 'Git selection follows viewport', 100);+462
return rr.top >= vr.top - 1 && rr.bottom <= vr.bottom + 1; })()`, 'Git selection follows viewport', 100); 463
await sgl('(slate-dispatch "git-select:section:untracked")'); 464
const touchExpandedBefore = await evalIn(tab, `[...document.querySelectorAll('.git-section')] 465
.find(e => e.textContent.includes('Untracked'))?.getAttribute('aria-expanded')`);packages/slate/test/git-status-view-test.sglmodified
@@ -250,12 +250,13 @@
250
(rows (git-status-rows (inline-view lines #t)))) 251
(assert-true (<= (length rows) 800)))) 252
−253
(test "Git header outline and command strip are separate layout regions"+253
(test "Git status keeps header and outline without a persistent command strip" 254
(let* ((markup (git-status-render (ready-view))) 255
(regions (filter pair? (cddr markup))))−256
(assert-equal '(header div nav) (map car regions))+256
(assert-equal '(header div) (map car regions)) 257
(assert-true (string-contains? (rendered-text (cadr regions)) "git-outline"))−258
(assert-true (string-contains? (rendered-text (caddr regions)) "Git commands"))))+258
(assert-false (string-contains? (rendered-text markup) "git-command-strip"))+259
(assert-false (string-contains? (rendered-text (car regions)) "git-header-icon")))) 260
261
(test "layout changes keep one diff control and preserve selection folds and models" 262
(let* ((view (inline-viewpackages/slate/test/help-command-test.sglmodified
@@ -22,7 +22,9 @@
22
(lambda (state suffix) state)) 23
24
(configure-help-commands!−25
#{ active-keymap-layers: (lambda (state) layers)+25
#{ global-keymap: (lambda (state) #{ "C-x" #{ "2" "split-down" } })+26
active-keymap: (lambda (state) (keymap-merge (map (lambda (layer) (dict-ref layer map: #{})) layers)))+27
active-keymap-layers: (lambda (state) layers) 28
user-command-ref: (lambda (name) (and (string=? name "hello") (lambda () #t))) 29
user-command-doc: (lambda (name) "user greeting") 30
user-commands: (lambda () (list "hello"))@@ -140,3 +142,10 @@
142
(lambda (s message) (error 'test message))))) 143
(assert-equal (dict-ref candidate command-name: "") "thing:deep:value") 144
(assert-true (string-contains? (dict-ref result slate/echo: "") "suffix value"))))+145
+146
(test "Help menu opens the global map and clears an armed prefix"+147
(let* ((state (command-run #{ slate/pending-prefix: #{ "x" "old-command" } } "which-key-global"))+148
(panel (dict-ref state slate/which-key: #{})))+149
(assert-false (dict-ref state slate/pending-prefix: #f))+150
(assert-equal "Global key bindings" (dict-ref panel title: ""))+151
(assert-equal #{ "C-x" #{ "2" "split-down" } } (dict-ref panel map: #{}))))