Commit94b945e5Recorded8 Sep 2026Repositoryslate
Close completed commit drafts and remove stale view references
Changed
packages/slate/assets/sw.js | 2 +-
packages/slate/src/slate/commands/git/commit.sgl | 85 ++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------
packages/slate/src/slate/commands/navigation.sgl | 5 +----
packages/slate/src/slate/modes/git-commit.sgl | 1 +
packages/slate/src/slate/state.sgl | 40 +++++++++++++++++++++++++++++++++++++++-
packages/slate/test/git-commit-browser-gate.mjs | 22 +++++++++++++++++-----
packages/slate/test/git-commit-command-test.sgl | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
packages/slate/test/state-test.sgl | 20 ++++++++++++++++++++
8 files changed, 190 insertions(+), 54 deletions(-)Diff
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-commits-2';+20
const VERSION = '2026-09-08-git-commits-3'; 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/commands/git/commit.sglmodified
@@ -1,6 +1,6 @@
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)+3
(slate modes editor) (slate modes git-commit) (slate modes welcome) 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!)@@ -13,18 +13,21 @@
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))))+16
(define (current-draft state original)+17
(let ((draft (find-view (views state) (view-name original))))+18
(and draft+19
(eq? (dict-ref draft slate/commit-session: #f)+20
(dict-ref original slate/commit-session: #f))+21
draft))) 22
(define (on-composer state fn) 23
(let ((v (active-view state))) 24
(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))))+25
(define (edit state original phase message details)+26
(if (not (current-draft state original)) state+27
(update-view state (view-name original)+28
(lambda (v)+29
(dict-set (dict-set (dict-set v slate/commit-phase: phase)+30
slate/commit-error: message) slate/commit-details: details))))) 31
(define (request state source verb args ready fail) 32
(let ((sync? #t) (next state)) 33
(define (deliver handler value)@@ -64,21 +67,22 @@
67
(lambda (state v) 68
(if (busy? v) state 69
(let ((name (view-name v)))−67
(request (edit state name 'refreshing "" "") (dict-ref v slate/commit-source: #f) "git/status" #{ repo: (repo v) }+70
(request (edit state v 'refreshing "" "") (dict-ref v slate/commit-source: #f) "git/status" #{ repo: (repo v) } 71
(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 "" "") 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." "")))+72
(cond ((not (current-draft current v)) current)+73
((and (dict? reply) (git-repository=? (repo v) (dict-ref reply repo: #f))+74
(git-snapshot-key (dict-ref reply snapshot: #f)))+75
(let* ((diff (new-diff (dict-ref v slate/commit-source: #f) reply))+76
(next (update-view (edit current v 'ready "" "") name+77
(lambda (draft)+78
(dict-set (dict-set draft slate/commit-data: reply) slate/git-diff-model: diff)))))+79
(git-diff-model-invalidate! (git-diff-model v))+80
((op set-state:) next)+81
(git-diff-model-load! diff)+82
((op state:))))+83
(else (edit current v 'uncertain "Could not validate the refreshed review." "")))) 84
(lambda (current error)−81
(edit current name 'uncertain "Could not refresh. Your draft is preserved." ""))))))))+85
(edit current v 'uncertain "Could not refresh. Your draft is preserved." "")))))))) 86
(define (submit state) 87
(on-composer state 88
(lambda (state v)@@ -87,7 +91,7 @@
91
((eq? (dict-ref v slate/commit-phase: #f) 'uncertain) 92
(with-echo state "Inspect history and refresh the review before trying again.")) 93
((string=? (string-trim (view-text v)) "")−90
(edit state (view-name v) 'ready "Write a commit message first." ""))+94
(edit state v 'ready "Write a commit message first." "")) 95
(else 96
(let* ((name (view-name v)) (repository (repo v)) (message (view-text v)) 97
(models (filter@@ -99,7 +103,7 @@
103
(let ((locks (map git-status-model-lock! models))) 104
(define (settle reply) 105
(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"+106
(request (edit state v 'pending "" "") (dict-ref v slate/commit-source: #f) "git/commit" 107
#{ repo: repository message: message 108
expected-snapshot: (dict-ref (data v) snapshot: #{}) 109
expected-branch: (dict-ref (dict-ref (data v) branch: #{}) head: "") }@@ -108,25 +112,28 @@
112
(git-repository=? repository (dict-ref reply repo: #f))))) 113
(settle (and valid? reply)) 114
(cond−111
((not valid?) (edit current name 'uncertain "Could not verify the commit result. Inspect history before retrying." ""))+115
((not valid?) (edit current v 'uncertain "Could not verify the commit result. Inspect history before retrying." "")) 116
((eq? (dict-ref reply applied: #f) #t) 117
(for-each (lambda (view) 118
(let ((log (git-log-model view))) 119
(when (and log (git-repository=? repository (git-log-model-repo log))) 120
(git-log-model-refresh! log)))) 121
(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+122
(let* ((draft (current-draft current v))+123
(next+124
(cond+125
((not draft) current)+126
((string=? (view-text draft) message)+127
(let* ((base (if (= (length (views current)) 1)+128
(add-view current (make-welcome-view))+129
current))+130
(closed (remove-view base name+131
(dict-ref v slate/commit-return: #f))))+132
(git-diff-model-invalidate! (git-diff-model draft))+133
closed))+134
(else (edit current v 'ready "" "")))))+135
(with-echo next "Commit created.")))+136
(else (edit current v 137
(if (dict-ref reply uncertain: #f) 'uncertain 'ready) 138
(dict-ref reply message: "Commit failed; draft preserved.") 139
(dict-ref reply details: ""))))))@@ -134,7 +141,7 @@
141
(settle #f) 142
;; No automatic resend, including reconnect. A lost 143
;; reply may follow a successful Git ref update.−137
(edit current name 'uncertain+144
(edit current v 'uncertain 145
(if (dict? error) (dict-ref error message: "Connection lost. Inspect history before retrying.") 146
"Connection lost. Inspect history before retrying.") ""))))))))))) 147
(define (show-review review?)packages/slate/src/slate/commands/navigation.sglmodified
@@ -94,10 +94,7 @@
94
(if (null? remaining) 95
(with-echo state "can't close the last view") 96
(with-echo−97
(dict-set (update-active-space state−98
(lambda (space)−99
(space-set-focused-view space (view-name (list-last remaining)))))−100
slate/views: remaining)+97
(remove-view state current #f) 98
(string-append "closed " current))))) 99
100
(define (active-view-name-safe state)packages/slate/src/slate/modes/git-commit.sglmodified
@@ -84,6 +84,7 @@
84
(define (make-git-commit-view name source data return-view model) 85
(view-rehighlight-initial 86
(git-commit-mode slate/name: name slate/commit-source: source+87
slate/commit-session: (vector #f) 88
slate/buffer: (make-buffer "") slate/undo: (make-undo) slate/hl-cache: (vector) 89
slate/result: "" slate/commit-data: data slate/commit-return: return-view 90
slate/git-diff-model: model slate/commit-phase: 'ready)))))packages/slate/src/slate/state.sglmodified
@@ -8,11 +8,12 @@
8
(define-library (slate state) 9
(import (sigil core) 10
(slate list)+11
(slate pane) 12
(slate space) 13
(slate view)) 14
(export views spaces find-view find-space 15
active-space active-space-name update-active-space−15
active-view update-view update-active add-view focus-view ensure-view+16
active-view update-view update-active add-view focus-view ensure-view remove-view 17
echo-transient with-echo) 18
(begin 19
@@ -57,6 +58,43 @@
58
(define (add-view state view) 59
(dict-set state slate/views: (append (views state) (list view)))) 60
+61
;; Remove every navigation reference, including panes in other spaces and+62
;; saved layouts. Returning to an old layout must not revive a closed view.+63
;; Resource teardown belongs to the caller; the last view is retained.+64
(define (remove-view state name preferred)+65
(let* ((remaining (filter (lambda (v) (not (string=? (view-name v) name)))+66
(views state)))+67
(replacement (and (pair? remaining)+68
(view-name (or (and preferred (find-view remaining preferred))+69
(list-last remaining))))))+70
(define (layout tree)+71
(fold-left+72
(lambda (tree leaf)+73
(if (string=? (leaf-view leaf) name)+74
(tree-map-leaf tree (leaf-id leaf)+75
(lambda (leaf) (leaf-set-view leaf replacement)))+76
tree))+77
tree (tree-leaves tree)))+78
(define (history snapshots)+79
(map (lambda (snapshot)+80
(dict-set snapshot layout: (layout (dict-ref snapshot layout: #f))))+81
snapshots))+82
(if (or (not replacement) (not (find-view (views state) name))) state+83
(dict-set+84
(dict-set+85
(dict-set state slate/views: remaining)+86
slate/recent-views:+87
(filter (lambda (recent) (not (string=? recent name)))+88
(dict-ref state slate/recent-views: '())))+89
slate/spaces:+90
(map (lambda (space)+91
(dict-set+92
(dict-set+93
(dict-set space space/layout: (layout (space-layout space)))+94
space/hist-back: (history (dict-ref space space/hist-back: '())))+95
space/hist-fwd: (history (dict-ref space space/hist-fwd: '()))))+96
(spaces state))))))+97
98
(define (focus-view state name) 99
(update-active-space state 100
(lambda (space) (space-set-focused-view space name))))packages/slate/test/git-commit-browser-gate.mjsmodified
@@ -36,6 +36,7 @@ try {
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
let stage = 'browser startup'; 40
const pending = new Map(); 41
socket.onmessage = event => { 42
const message = JSON.parse(event.data);@@ -49,7 +50,7 @@ try {
50
}; 51
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { 52
const id = ++nextId;−52
const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 15000);+53
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method} (${stage})`)); }, 45000); 54
pending.set(id, {resolve: value => { clearTimeout(timer); resolve(value); }, 55
reject: error => { clearTimeout(timer); reject(error); }}); 56
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));@@ -67,8 +68,9 @@ try {
68
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); 69
return result.result.value; 70
};−70
async function waitFor(expression, label) {−71
for (let i = 0; i < 360; i++) {+71
async function waitFor(expression, label, attempts = 360) {+72
stage = label;+73
for (let i = 0; i < attempts; i++) { 74
if (await evaluate(expression)) return; 75
if(errors.length)throw new Error(`Browser error while waiting for ${label}: ${errors.join(" | ")}`); 76
await sleep(100);@@ -97,8 +99,10 @@ try {
99
const shot=await send('Page.captureScreenshot',{format:'png'},session); 100
fs.writeFileSync(path.join(process.env.GIT_COMMIT_SCREENSHOTS,name+'.png'),Buffer.from(shot.data,'base64')); 101
};−100
await waitFor("document.getElementById('status')?.textContent==='ready'",'boot');+102
await waitFor("document.getElementById('status')?.textContent==='ready'",'boot',1800); 103
await waitFor("window.slateEval('(slate-node-connected?)')==='1'",'node connection');+104
// Connection readiness precedes fs/home and its startup-view hook.+105
await waitFor(`window.slateEval('(begin (import (slate root)) (if (root-home local:) "yes" "no"))')==='yes'`,'local root initialization'); 106
await sgl(`(begin (import (slate root)) (register-root! commitfixture: #{ provider: local: home: ${JSON.stringify(fixture.repo)} host: "localhost" class: machine: }))`); 107
await dispatch('openroot:commitfixture'); 108
await waitFor("!!document.querySelector('.folder')",'repository root');@@ -164,13 +168,21 @@ try {
168
await screenshot('mobile-write'); 169
await click('.accessory-bar [data-slate-click="git-commit-submit"]'); 170
await waitFor("!!document.querySelector('.git-status')",'initial commit success');+171
await sgl('(begin (import (slate command) (slate state) (slate modes git-commit)) (define gate-composer-count -1) (install-builtin-command! "gate-count-composers" "Gate count composers" "test" \'gate (lambda (state) (set! gate-composer-count (length (filter git-commit-view? (views state)))) state)))');+172
await dispatch('gate-count-composers');+173
assert.equal(await sgl('(number->string gate-composer-count)'),'0','successful composer remains registered');+174
await dispatch('view-switch-recent');+175
assert.equal(await evaluate("!!document.querySelector('.git-compose')"),false,'completed composer resurfaced');+176
await dispatch('view-switch-recent');+177
await waitFor("!!document.querySelector('.git-status')",'return to status through recent views');+178
179
assert.equal(git(['show','HEAD:README.md']),'Staged commit content'); 180
assert.equal(git(['log','-1','--format=%B']),message.trim()); 181
assert.ok(git(['diff']).includes('Unstaged content stays out')); 182
183
git(['add','README.md']);git(['config','commit.gpgsign','true']); 184
await dispatch('git-refresh');−173
await waitFor("!!document.querySelector('.git-staged')",'new staged content');+185
await waitFor("!!document.querySelector('.git-staged') && !document.body.innerText.includes('Git · refreshing…')",'new staged content after refresh'); 186
await dispatch('git-commit-create'); 187
await waitFor("!!document.querySelector('.git-compose .editor-content') && !document.querySelector('.git-compose-message')?.inert",'signing composer'); 188
await dispatch('git-commit-write');packages/slate/test/git-commit-command-test.sglmodified
@@ -1,7 +1,10 @@
−1
(import (sigil test) (slate command) (slate commands git)+1
(import (sigil test) (slate command) (slate commands git) (slate commands navigation) 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
(install-navigation-commands!)+6
(define STATUS-HELD #f)+7
(define HOLD-STATUS #f) 8
(define STATE #{}) 9
(define HELD #f) 10
(define COUNT 0)@@ -13,7 +16,8 @@
16
#{ git-request: 17
(lambda (source verb args ready fail) 18
(cond ((string=? verb "git/discover") (ready #{ version: 1 node-repo: REPO }))−16
((string=? verb "git/status") (ready DATA))+19
((string=? verb "git/status")+20
(if HOLD-STATUS (set! STATUS-HELD ready) (ready DATA))) 21
((string=? verb "git/diff") 22
(ready #{ version: 1 repo: REPO snapshot: SNAP files: '() complete: #t payload-bytes: 0 })) 23
((string=? verb "git/commit")@@ -23,6 +27,8 @@
27
(register-root! commit-test: #{ provider: commit-test: host: "test" class: machine: }) 28
(define (run command) (set! STATE (command-run STATE command))) 29
(define (setup!)+30
(set! HOLD-STATUS #f)+31
(set! STATUS-HELD #f) 32
(set! HELD #f) 33
(set! COUNT 0) 34
(set! STATE #{ slate/views: (list (view slate/name: "plain"@@ -73,10 +79,65 @@
79
(run "git-commit-submit") 80
(assert-equal 2 COUNT)) 81
−76
(test "successful commit returns to status and clears only the submitted draft"+82
(test "successful commit removes the submitted draft" 83
(setup!) 84
(let ((name (view-name (active-view STATE)))) 85
(run "git-commit-submit") 86
((cadr HELD) #{ version: 1 repo: REPO applied: #t status: DATA oid: "new" }) 87
(assert-false (git-commit-view? (active-view STATE)))−82
(assert-equal "" (view-text (find-view (views STATE) name)))))+88
(assert-false (find-view (views STATE) name))))+89
+90
(test "commit completion does not steal focus from another view"+91
(setup!)+92
(let ((name (view-name (active-view STATE))))+93
(run "git-commit-submit")+94
(set! STATE (focus-view STATE "plain"))+95
((cadr HELD) #{ version: 1 repo: REPO applied: #t status: DATA oid: "new" })+96
(assert-equal "plain" (view-name (active-view STATE)))+97
(assert-false (find-view (views STATE) name))))+98
+99
(test "commit completion preserves a message edited since submission"+100
(setup!)+101
(run "git-commit-submit")+102
(set! STATE (update-active STATE+103
(lambda (v)+104
(view-set-buffer v (apply-buffer+105
(buffer-apply (view-buffer v) (op-set-all "New message")))))))+106
((cadr HELD) #{ version: 1 repo: REPO applied: #t status: DATA oid: "new" })+107
(assert-equal "New message" (view-text (active-view STATE))))+108
+109
(test "late commit success cannot close a replacement draft with the same text"+110
(setup!)+111
(run "git-commit-submit")+112
(let ((reply (cadr HELD))+113
(return (dict-ref (active-view STATE) slate/commit-return: #f)))+114
(run "close-view")+115
(set! STATE (focus-view STATE return))+116
(run "git-commit-create")+117
(set! STATE (update-active STATE+118
(lambda (v)+119
(view-set-buffer v (apply-buffer+120
(buffer-apply (view-buffer v) (op-set-all "Keep this draft\n")))))))+121
(reply #{ version: 1 repo: REPO applied: #t status: DATA oid: "new" })+122
(assert-true (git-commit-view? (active-view STATE)))+123
(assert-equal "Keep this draft\n" (view-text (active-view STATE)))))+124
+125
(test "late refresh cannot replace a killed and reopened draft's review"+126
(setup!)+127
(set! HOLD-STATUS #t)+128
(run "git-commit-refresh")+129
(let ((reply STATUS-HELD)+130
(return (dict-ref (active-view STATE) slate/commit-return: #f)))+131
(run "close-view")+132
(set! STATE (focus-view STATE return))+133
(run "git-commit-create")+134
(reply (dict-set DATA snapshot: (dict-set SNAP head-oid: "obsolete")))+135
(assert-equal SNAP (dict-ref (dict-ref (active-view STATE) slate/commit-data: #{}) snapshot: #f))))+136
+137
(test "successful commit closes even if every other view was killed while waiting"+138
(setup!)+139
(run "git-commit-submit")+140
(set! STATE (dict-set STATE slate/views: (list (active-view STATE))))+141
((cadr HELD) #{ version: 1 repo: REPO applied: #t status: DATA oid: "new" })+142
(assert-false (git-commit-view? (active-view STATE)))+143
(assert-equal 1 (length (views STATE))))packages/slate/test/state-test.sglmodified
@@ -74,3 +74,23 @@
74
(next (with-echo state "still visible"))) 75
(assert-equal (dict-ref next slate/echo: #f) "still visible") 76
(assert-equal (length (dict-ref next slate/views: '())) 2)))+77
+78
(test "removing a view cleans panes, recent views and saved layouts across spaces"+79
(let* ((split (space-record-layout (space-split main-space 'row)))+80
(state (dict-set+81
(dict-set base-state slate/recent-views: '("editor" "other"))+82
slate/spaces: (list split (dict-set split space/name: "side"))))+83
(next (remove-view state "editor" "other")))+84
(assert-false (find-view (views next) "editor"))+85
(assert-equal '("other") (dict-ref next slate/recent-views: #f))+86
(for-each+87
(lambda (space)+88
(assert-equal '("other" "other") (map leaf-view (space-leaves space)))+89
(assert-equal '("other" "other")+90
(map leaf-view (space-leaves (space-history-back space)))))+91
(spaces next))))+92
+93
(test "removing a missing or last view leaves state intact"+94
(assert-equal base-state (remove-view base-state "missing" #f))+95
(let ((only (dict-set base-state slate/views: (list editor))))+96
(assert-equal only (remove-view only "editor" #f))))