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 (verified
packages/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))))