Implement Qt recentf mode

ober

92bcd63a14fa5ddbf6f974a73e47b7794b451b38

diff --git a/src/jerboa-emacs/persist.ss b/src/jerboa-emacs/persist.ss
index fcba05e..be68fbe 100644
--- a/src/jerboa-emacs/persist.ss
+++ b/src/jerboa-emacs/persist.ss
@@ -8,6 +8,7 @@
   ;; Recent files
   *recent-files*
   *recent-files-max*
+  *recentf-mode*
   recent-files
   recent-files-max
   recent-files-add!
@@ -160,12 +161,13 @@
 
 (def *recent-files* '())
 (def *recent-files-max* 50)
+(def *recentf-mode* #t)
 (def (recent-files) *recent-files*)
 (def (recent-files-max) *recent-files-max*)
 
 (def (recent-files-add! path)
   "Add a file path to the recent files list. Deduplicates and limits size."
-  (when (and (string? path) (> (string-length path) 0))
+  (when (and *recentf-mode* (string? path) (> (string-length path) 0))
     ;; Normalize: expand to absolute path
     (let ((abs-path (path-expand path)))
       ;; Remove existing entry (move to front)
diff --git a/src/jerboa-emacs/qt/commands-parity5.ss b/src/jerboa-emacs/qt/commands-parity5.ss
index 3ac3c81..96e2697 100644
--- a/src/jerboa-emacs/qt/commands-parity5.ss
+++ b/src/jerboa-emacs/qt/commands-parity5.ss
@@ -49,6 +49,7 @@
         :jerboa-emacs/qt/commands-parity3b
         :jerboa-emacs/qt/commands-parity4
         (only-in :jerboa-emacs/persist
+          *recentf-mode* recent-files-load! recent-files-save!
           *which-key-mode* *which-key-delay*
           *copilot-mode* *copilot-api-key* *copilot-model*
           *copilot-api-url* *copilot-suggestion* *copilot-suggestion-pos*
@@ -1357,6 +1358,18 @@
     (if *winner-mode*
       "Winner mode ON"
       "Winner mode OFF")))
+(def (cmd-recentf-mode app)
+  "Toggle Qt recentf-mode file tracking."
+  (set! *recentf-mode* (not *recentf-mode*))
+  (if *recentf-mode*
+    (recent-files-load!)
+    (recent-files-save!))
+  (hash-put! *qt-toggle-states* 'recentf-mode *recentf-mode*)
+  (hash-put! *qt-toggle-states* 'toggle-recentf-mode *recentf-mode*)
+  (echo-message! (app-state-echo app)
+    (if *recentf-mode*
+      "Recentf mode ON"
+      "Recentf mode OFF")))
 (def *qt-run-with-timer-counter* 0)
 
 (def (next-run-with-timer-name!)
@@ -1466,6 +1479,8 @@
       (cons 'toggle-global-display-line-numbers cmd-global-display-line-numbers-mode)
       (cons 'winner-mode cmd-winner-mode)
       (cons 'toggle-winner-mode cmd-winner-mode)
+      (cons 'recentf-mode cmd-recentf-mode)
+      (cons 'toggle-recentf-mode cmd-recentf-mode)
       (cons 'run-with-timer cmd-run-with-timer)
       (cons 'ibuffer-mark cmd-ibuffer-mark)
       (cons 'ibuffer-delete cmd-ibuffer-delete)
diff --git a/src/jerboa-emacs/qt/commands-shell.ss b/src/jerboa-emacs/qt/commands-shell.ss
index 5c11371..25e16ed 100644
--- a/src/jerboa-emacs/qt/commands-shell.ss
+++ b/src/jerboa-emacs/qt/commands-shell.ss
@@ -16,6 +16,7 @@
         (only-in :jsh/registry builtin-lookup builtin-register!)
         (only-in :jerboa-emacs/persist theme-settings-save! theme-settings-load!
                  mx-history-save! mx-history-load!
+                 *recent-files*
                  *auto-fill-mode* *fill-column*
                  *abbrev-table* *abbrev-mode-enabled*
                  *delete-trailing-whitespace-on-save*)
diff --git a/src/jerboa-emacs/qt/commands-vcs.ss b/src/jerboa-emacs/qt/commands-vcs.ss
index e5d22de..91b46fb 100644
--- a/src/jerboa-emacs/qt/commands-vcs.ss
+++ b/src/jerboa-emacs/qt/commands-vcs.ss
@@ -21,6 +21,8 @@
         :jerboa-emacs/qt/echo
         :jerboa-emacs/qt/highlight
         :jerboa-emacs/qt/modeline
+        (only-in :jerboa-emacs/persist
+          *recent-files* recent-files-save! recent-files-load! recent-files-cleanup!)
         :jerboa-emacs/qt/commands-core
         :jerboa-emacs/qt/commands-core2
         :jerboa-emacs/qt/commands-edit
@@ -669,59 +671,7 @@
         (echo-message! (app-state-echo app)
           (string-append "Would kill " (number->string killed) " matching buffers"))))))
 
-(def *recent-files* [])
-(def *recent-files-max* 50)
-(def *recent-files-path*
-  (path-expand ".jemacs-recent-files" (user-info-home (user-info (user-name)))))
-
-(def (recent-files-add! path)
-  "Add a file path to the recent files list (most recent first, no duplicates)."
-  (when (and path (string? path) (> (string-length path) 0))
-    (let ((abs-path (path-expand path)))
-      ;; Remove existing entry if present, then prepend
-      (set! *recent-files*
-        (cons abs-path
-          (let loop ((files *recent-files*) (acc []))
-            (cond
-              ((null? files) (reverse acc))
-              ((string=? (car files) abs-path) (loop (cdr files) acc))
-              (else (loop (cdr files) (cons (car files) acc)))))))
-      ;; Trim to max size
-      (when (> (length *recent-files*) *recent-files-max*)
-        (set! *recent-files*
-          (let loop ((files *recent-files*) (n 0) (acc []))
-            (if (or (null? files) (>= n *recent-files-max*))
-              (reverse acc)
-              (loop (cdr files) (+ n 1) (cons (car files) acc))))))
-      ;; Save to disk
-      (recent-files-save!))))
-
-(def (recent-files-save!)
-  "Persist recent files list to disk."
-  (with-catch
-    (lambda (e) #f)
-    (lambda ()
-      (call-with-output-file *recent-files-path*
-        (lambda (port)
-          (for-each (lambda (f) (display f port) (newline port))
-                    *recent-files*))))))
-
-(def (recent-files-load!)
-  "Load recent files list from disk."
-  (with-catch
-    (lambda (e) #f)
-    (lambda ()
-      (when (file-exists? *recent-files-path*)
-        (set! *recent-files*
-          (call-with-input-file *recent-files-path*
-            (lambda (port)
-              (let loop ((acc []))
-                (let ((line (read-line port)))
-                  (if (eof-object? line)
-                    (reverse acc)
-                    (if (> (string-length line) 0)
-                      (loop (cons line acc))
-                      (loop acc))))))))))))
+;;; Recent files are persisted by :jerboa-emacs/persist.
 
 (def (cmd-list-recent-files app)
   "Show list of recently opened files."
@@ -737,7 +687,7 @@
 
 (def (cmd-clear-recent-files app)
   "Clear the recent files list."
-  (set! *recent-files* [])
+  (set! *recent-files* '())
   (recent-files-save!)
   (echo-message! (app-state-echo app) "Recent files cleared"))
 
@@ -770,11 +720,7 @@
 
 (def (cmd-recentf-cleanup app)
   "Remove non-existent files from the recent files list."
-  (let* ((before (length *recent-files*))
-         (cleaned (filter file-exists? *recent-files*))
-         (removed (- before (length cleaned))))
-    (set! *recent-files* cleaned)
-    (recent-files-save!)
+  (let ((removed (recent-files-cleanup!)))
     (echo-message! (app-state-echo app)
       (string-append "Removed " (number->string removed) " non-existent files"))))
 
diff --git a/src/jerboa-emacs/qt/commands-vcs2.ss b/src/jerboa-emacs/qt/commands-vcs2.ss
index fe2f848..05f96e6 100644
--- a/src/jerboa-emacs/qt/commands-vcs2.ss
+++ b/src/jerboa-emacs/qt/commands-vcs2.ss
@@ -20,6 +20,7 @@
         :jerboa-emacs/qt/echo
         :jerboa-emacs/qt/highlight
         :jerboa-emacs/qt/modeline
+        (only-in :jerboa-emacs/persist recent-files-add!)
         :jerboa-emacs/qt/commands-core
         :jerboa-emacs/qt/commands-core2
         :jerboa-emacs/qt/commands-edit
diff --git a/src/jerboa-emacs/qt/commands.ss b/src/jerboa-emacs/qt/commands.ss
index 53615aa..f39c07a 100644
--- a/src/jerboa-emacs/qt/commands.ss
+++ b/src/jerboa-emacs/qt/commands.ss
@@ -118,6 +118,7 @@
         (only-in :jerboa-emacs/persist
                  buffer-local-set! buffer-local-get
                  save-place-save! save-place-load!
+                 recent-files-add! recent-files-load!
                  save-place-remember! save-place-restore
                  *save-place-enabled* *require-final-newline*
                  *centered-cursor-mode*
diff --git a/tests/test-qt-part2.ss b/tests/test-qt-part2.ss
index 81f14ff..d085329 100644
--- a/tests/test-qt-part2.ss
+++ b/tests/test-qt-part2.ss
@@ -27,6 +27,8 @@
         (only (jerboa-emacs qt commands-edit) *line-numbers-visible*)
         (only (jerboa-emacs qt commands-core)
               *so-long-threshold* *winner-mode* *winner-history* *winner-future*)
+        (only (jerboa-emacs persist)
+              *recentf-mode* *recent-files* recent-files-add!)
         (only (jerboa-emacs qt commands-parity5) schedule-user-timer!)
         (only (jerboa-emacs async) master-timer-tick!)
         (jerboa-emacs qt keymap)
@@ -108,7 +110,7 @@
       (loop (+ i 1)
             (cons (string-append "line " (number->string i) "\n") acc)))))
 
-(display "\n=== Qt Part2 Groups 44-62 ===\n")
+(display "\n=== Qt Part2 Groups 44-63 ===\n")
 
 (test-case "group44 qt key/mouse fidelity"
   (check (qt-key-event->string QT_KEY_RETURN 0 "") => "C-m")
@@ -336,9 +338,43 @@
           (set! *winner-mode* old-mode)
           (set! *winner-history* old-history)
           (set! *winner-future* old-future))))))
+(test-case "group63 recentf-mode controls shared recent files"
+  (let-values (((ed w app) (make-qt-test-app "part2-63")))
+    (let ((old-mode *recentf-mode*)
+          (old-files *recent-files*)
+          (old-home (getenv "HOME" #f))
+          (dir "/tmp/jemacs-recentf-mode-test")
+          (path "/tmp/jemacs-recentf-mode-test/file.txt"))
+      (dynamic-wind
+        (lambda ()
+          (with-catch (lambda (e) #f)
+            (lambda () (create-directory dir)))
+          (call-with-output-file path
+            (lambda (port) (display "recentf" port))
+            'replace)
+          (setenv "HOME" dir)
+          (set! *recentf-mode* #t)
+          (set! *recent-files* '()))
+        (lambda ()
+          (execute-command! app 'recentf-mode)
+          (check *recentf-mode* => #f)
+          (check (echo-state-message (app-state-echo app)) => "Recentf mode OFF")
+          (recent-files-add! path)
+          (check *recent-files* => '())
+          (execute-command! app 'toggle-recentf-mode)
+          (check *recentf-mode* => #t)
+          (check (echo-state-message (app-state-echo app)) => "Recentf mode ON")
+          (recent-files-add! path)
+          (check *recent-files* => (list path)))
+        (lambda ()
+          (set! *recentf-mode* old-mode)
+          (set! *recent-files* old-files)
+          (setenv "HOME" (or old-home "/tmp"))
+          (with-catch (lambda (e) #f)
+            (lambda () (delete-file path))))))))
 (newline)
 (let ([total (+ *pass* *fail*)])
-  (printf "Part2 results: ~a/~a tests passed (groups 44-62)~n" *pass* total)
+  (printf "Part2 results: ~a/~a tests passed (groups 44-63)~n" *pass* total)
   (when (> *fail* 0)
     (printf "FAILED: ~a test(s)~n" *fail*))
   (when (= *fail* 0)