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 browser
packages/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 environment
packages/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 (verified
packages/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-hl
packages/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-view
packages/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: #{}))))