{{ message }}
Repository navigation
Expand file tree
/
Copy pathai-code-file.el
More file actions
516 lines (487 loc) · 24.1 KB
/
Copy pathai-code-file.el
File metadata and controls
516 lines (487 loc) · 24.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
;;; ai-code-file.el --- File operations for AI code interface -*- lexical-binding: t; -*-
;; Author: Kang Tu <tninja@gmail.com>
;; SPDX-License-Identifier: Apache-2.0
;;; Commentary:
;; This file provides file operation functionality for the AI Code Interface package.
;;; Code:
(require 'magit)
(require 'dired)
(require 'comint)
(require 'subr-x)
(require 'ai-code-input)
(require 'ai-code-prompt-mode)
(declare-function ai-code-read-string "ai-code-input")
(declare-function ai-code--get-context-files-string "ai-code-input")
(declare-function ai-code--insert-prompt "ai-code-prompt-mode" (prompt-text))
(declare-function ai-code--process-word-for-filepath "ai-code-prompt-mode" (word git-root-truename))
(declare-function ai-code-call-gptel-sync "ai-code-prompt-mode" (prompt))
(declare-function projectile-project-root "projectile")
(defcustom ai-code-sed-command "sed"
"GNU sed command used to apply prompts to files."
:type 'string
:group 'ai-code)
;; Variables that will be defined in ai-code-interface.el
(defvar ai-code-use-prompt-suffix)
(defvar ai-code-prompt-suffix)
(defvar ai-code-cli)
;;;###autoload
(defun ai-code-copy-buffer-file-name-to-clipboard (&optional arg)
"Copy the current buffer's file path or selected text to clipboard.
If in a magit status buffer, copy the current branch name.
If in a Dired buffer, copy the file at point or directory path.
If in a regular file buffer with selected text, copy text with file path.
Otherwise, copy the file path of the current buffer.
With prefix argument ARG \[universal-argument], always return full path
instead of processed path. File paths are processed to relative paths
with @ prefix if within git repo."
(interactive "P")
(let ((path-to-copy
(cond
;; If current buffer is a magit status buffer
((derived-mode-p 'magit-status-mode)
(magit-get-current-branch))
;; If current buffer is a file, use existing logic
((buffer-file-name)
(let* ((git-root (magit-toplevel))
(git-root-truename (when git-root (file-truename git-root))))
(if (use-region-p)
(let ((processed-file (if (and git-root-truename (not arg))
(ai-code--process-word-for-filepath (buffer-file-name) git-root-truename)
(buffer-file-name))))
(format "%s in %s"
(buffer-substring-no-properties (region-beginning) (region-end))
processed-file))
(if (and git-root-truename (not arg))
(ai-code--process-word-for-filepath (buffer-file-name) git-root-truename)
(buffer-file-name)))))
;; If current buffer is a dired buffer
((derived-mode-p 'dired-mode)
(let* ((file-at-point (ignore-errors (dired-get-file-for-visit)))
(git-root (magit-toplevel))
(git-root-truename (when git-root (file-truename git-root))))
(if file-at-point
;; If there's a file under cursor, copy its processed path
(if (and git-root-truename (not arg))
(ai-code--process-word-for-filepath file-at-point git-root-truename)
file-at-point)
;; If no file under cursor, copy the dired directory path
(let ((dir-path (dired-current-directory)))
(if (and git-root-truename (not arg))
(ai-code--process-word-for-filepath dir-path git-root-truename)
dir-path)))))
;; For other buffer types, return nil
(t nil))))
(if path-to-copy
(progn
(kill-new path-to-copy)
(message "copied %s to clipboard" path-to-copy))
(message "No file path available to copy"))))
;;;###autoload
(defun ai-code-open-clipboard-file-path-as-dired ()
"Open the file or directory path from clipboard in Dired.
If the clipboard contains a valid file path, open its directory in Dired
in another window and move the cursor to that file. If the clipboard
contains a directory path, open it directly in Dired in another window."
(interactive)
(let ((path (current-kill 0)))
(if (and path (file-exists-p path))
(if (file-directory-p path)
(dired-other-window path)
(let* ((dir (file-name-directory path))
(file (file-name-nondirectory path))
(dired-buffer (dired-other-window dir)))
(with-current-buffer dired-buffer
(goto-char (point-min))
(when (search-forward (regexp-quote file) nil t)
(goto-char (match-beginning 0))))))
(message "Clipboard does not contain a valid file or directory path"))))
(defvar ai-code-run-file-history nil
"History list for `ai-code-run-current-file' commands.")
(defun ai-code--run-command-in-comint (command run-directory display-name)
"Run COMMAND in a comint buffer using RUN-DIRECTORY and DISPLAY-NAME.
COMMAND is parsed into a program and its SWITCHES using
`split-string-and-unquote'. RUN-DIRECTORY is used as the default
directory for the process. DISPLAY-NAME is the buffer name applied to
the comint session. This function is a programmatic wrapper around
`comint-run'."
(let* ((command-parts (split-string-and-unquote command))
(program (car command-parts))
(switches (cdr command-parts)))
(unless program
(user-error "Command cannot be empty"))
(let* ((origin-window (selected-window))
(origin-buffer (window-buffer origin-window))
(effective-directory (or run-directory default-directory))
(comint-buffer-name (format "*%s*" (file-name-nondirectory program))))
(when-let* ((existing (get-buffer display-name)))
(unless (yes-or-no-p (format "Kill existing session %s? " display-name))
(user-error "Aborted running command: %s" command))
(when-let* ((proc (get-buffer-process existing)))
(delete-process proc))
(kill-buffer existing))
(let ((default-directory effective-directory))
(apply #'make-comint "Run" program nil switches))
(when (window-live-p origin-window)
(set-window-buffer origin-window origin-buffer))
(when-let* ((buffer (get-buffer comint-buffer-name)))
(with-current-buffer buffer
(rename-buffer display-name t)
(setq-local default-directory effective-directory))
(let ((session-window
(display-buffer buffer '((display-buffer-pop-up-window)
(inhibit-same-window . t)))))
(when (window-live-p session-window)
(select-window session-window))))
(get-buffer display-name))))
;;;###autoload
(defun ai-code-run-current-file ()
"Generate command to run current script file (.py, .js, .ts, or .sh).
Let user modify the command before running it in a comint buffer.
Maintains a dedicated history list for this command."
(interactive)
(let* ((current-file (buffer-file-name))
(file-ext (when current-file (file-name-extension current-file)))
(file-name (when current-file (file-name-nondirectory current-file)))
(last-command (when ai-code-run-file-history (car ai-code-run-file-history)))
(default-command
(cond
;; Check if current file is in the last run command
((and last-command file-name (string-match-p (regexp-quote file-name) last-command))
last-command)
;; Generate default command based on file extension
((string= file-ext "py")
(format "python %s" file-name))
((string= file-ext "js")
(format "node %s" file-name))
((string= file-ext "ts")
(format "npx tsx %s" file-name))
((string= file-ext "sh")
(format "bash %s" file-name))
(t nil))))
(unless current-file
(user-error "Current buffer is not visiting a file"))
(unless default-command
(user-error "Current file is not a .py, .js, .ts, or .sh file"))
(let ((command (read-string (format "Run command for %s: " file-name)
default-command
'ai-code-run-file-history)))
(let* ((run-directory (file-name-directory current-file))
(display-name (format "*ai-code-run-current-file: %s*"
(file-name-base file-name))))
(ai-code--run-command-in-comint command run-directory display-name)))))
;;;###autoload
(defun ai-code-apply-prompt-on-current-file ()
"Apply a user prompt to the current file and send to an AI CLI tool.
The file can be the one in the current buffer or at point in a Dired
buffer. It constructs a shell command:
sed \"1i <prompt>: \" <file> | <ai-code-cli>
and runs it in a compilation buffer."
(interactive)
(let* ((prompt (ai-code-read-string "Prompt: "))
(prompt-with-suffix (if (and ai-code-use-prompt-suffix ai-code-prompt-suffix)
(concat prompt ", " ai-code-prompt-suffix)
prompt))
(file-name (cond
((derived-mode-p 'dired-mode)
(dired-get-filename))
((buffer-file-name)
(buffer-file-name))
(t (user-error "Cannot determine the file name"))))
(command (format "%s \"1i %s: \" %s | %s"
ai-code-sed-command
(shell-quote-argument prompt-with-suffix)
(shell-quote-argument file-name)
ai-code-cli)))
(when file-name
(let* ((default-directory (file-name-directory file-name))
(buffer-name (format "*ai-code-apply-prompt: %s*"
(file-name-base file-name))))
(compilation-start
command
nil
(lambda (_mode)
(generate-new-buffer-name buffer-name)))))))
(defun ai-code--generate-shell-command (&optional initial-input)
"Generate shell command from user input or AI assistance.
Read initial command from user with INITIAL-INPUT as default.
If command starts with ':', treat as prompt for AI to generate command.
Return the final command string."
(let* ((initial-command (ai-code-read-string "Shell command: " initial-input))
;; if current buffer is Dired buffer, replace the * character
;; inside initial-command with file base name under cursor,
;; or marked files, separate with space
(initial-command
(if (and (derived-mode-p 'dired-mode)
(string-match-p "\\*" initial-command))
(let* ((files (ignore-errors (dired-get-marked-files)))
(file-names (when files
(delete-dups (mapcar #'file-name-nondirectory files)))))
(if file-names
(replace-regexp-in-string
(regexp-quote "*")
(mapconcat #'identity file-names " ")
initial-command
nil
t)
initial-command))
initial-command))
(command
(if (string-prefix-p ":" initial-command)
;; If command starts with :, treat as prompt for AI
(let* ((base-prompt (concat "Generate a shell command (pure command, no fense, no duplicate, in one line) for: " (substring initial-command 1)))
(prompt (if (derived-mode-p 'dired-mode)
(let* ((files (ignore-errors (dired-get-marked-files)))
(file-names (when files (delete-dups (mapcar #'file-name-nondirectory files)))))
(if file-names
(concat (format "For these files %s, " (mapconcat #'identity file-names " "))
base-prompt)
base-prompt))
base-prompt)))
(condition-case err
;; DONE: if ai-generated has more than one line,
;; only take the first line
(let* ((ai-generated (ai-code-call-gptel-sync prompt))
(first-line (when ai-generated
(car (split-string ai-generated "\n" t)))))
(when first-line
;; Ask user to confirm/edit the AI-generated command
(read-string "Shell command (AI generated): " (string-trim first-line))))
(error
(message "Failed to generate command with AI: %s" err)
"")))
;; Regular command, use as-is
initial-command)))
command))
;;;###autoload
(defun ai-code-shell-cmd (&optional initial-input)
"Run shell command in Dired directory or insert command in shell buffers.
If current buffer is a Dired buffer, get user input shell command with
`read-string', then run it under the directory of Dired buffer, in a
buffer with name as *ai-code-shell-cmd: <current-dir>*. If current
buffer is `shell-mode', `eshell-mode' or `sh-mode', get input and insert
command under cursor, do not run it. If the command starts with
\\=':\\=', it means it is a prompt. In this case, ask gptel to generate
the corresponding shell command, and call `ai-code-shell-cmd' with that
command as candidate. INITIAL-INPUT is the initial text to populate the
shell command prompt."
(interactive)
(cond
;; Handle shell modes: insert command without running
((memq major-mode '(shell-mode eshell-mode))
(let ((command (ai-code--generate-shell-command initial-input)))
(when (and command (not (string= command "")))
(insert command))))
;; Handle other modes: run command in compilation buffer
(t
(let* ((current-dir (cond
((derived-mode-p 'dired-mode)
(dired-current-directory))
(initial-input
default-directory)
(t nil))))
(unless current-dir
(user-error "Cannot determine working directory: requires either a Dired buffer or initial input"))
(let* ((command (ai-code--generate-shell-command initial-input))
(buffer-name (format "*ai-code-shell-cmd: %s*" (directory-file-name current-dir))))
(when (and command (not (string= command "")))
(let ((default-directory current-dir))
(compilation-start
command
nil
(lambda (_mode)
(generate-new-buffer-name buffer-name))))))))))
;;;###autoload
(defun ai-code-run-current-file-or-shell-cmd ()
"Run current file or shell command based on buffer state.
Call `ai-code-shell-cmd` when in Dired mode, shell modes or a region
is active; otherwise run the current file."
(interactive)
(cond
((derived-mode-p 'dired-mode)
(ai-code-shell-cmd))
((memq major-mode '(shell-mode eshell-mode))
(ai-code-shell-cmd))
((use-region-p)
(let ((initial-input (string-trim (buffer-substring-no-properties (region-beginning)
(region-end)))))
(ai-code-shell-cmd initial-input)))
(t
(ai-code-run-current-file))))
;;;###autoload
(defun ai-code-build-project ()
"Build the current project.
Check for build.sh in the project root and run it if found.
Otherwise, ask AI to generate a build command."
(interactive)
;; DONE: If projectile package, is available, first check the build.sh in
;; the projectile root dir. Otherwise, check the git root dir.
(let* ((proj-root (or (and (fboundp 'projectile-project-root)
(ignore-errors (projectile-project-root)))
(magit-toplevel)))
(build-script (when proj-root (expand-file-name "build.sh" proj-root))))
(if (and proj-root build-script (file-exists-p build-script))
(let ((default-directory proj-root)
(buffer-name (format "*ai-code-build: %s*" (file-name-nondirectory (directory-file-name proj-root)))))
(compilation-start "bash build.sh" nil (lambda (_mode) buffer-name)))
(let* ((repo-context (ai-code--format-repo-context-info))
(initial-input (concat "Build the current project. Provide the build command and execute it if possible. "
(when proj-root (format "\nProject root: %s" proj-root))
(when repo-context (concat "\n" repo-context))))
(prompt (ai-code-read-string "Send to AI: " initial-input)))
(ai-code--insert-prompt prompt)))))
(defvar ai-code--repo-context-info (make-hash-table :test #'equal)
"Hash table storing context info lists per Git repository root.")
(defun ai-code--store-context-entry (repo-root context)
"Store CONTEXT string for REPO-ROOT in `ai-code--repo-context-info'."
(let ((current (gethash repo-root ai-code--repo-context-info)))
(unless (member context current)
(setq current (cons context current)))
(puthash repo-root current ai-code--repo-context-info)))
;;;###autoload
(defun ai-code-add-context ()
"Capture current buffer context and store it per Git repository.
When no region is selected, use the full file path and current function
\(if any). When a region is active, use the file path with line range
in the form filepath#Lstart-Lend."
(interactive)
(let* ((current-root (magit-toplevel))
(all-roots (let ((roots '()))
(walk-windows
(lambda (w)
(with-current-buffer (window-buffer w)
(let ((root (magit-toplevel)))
(when (and root (not (member root roots)))
(push root roots)))))
nil 'current-frame)
(nreverse roots)))
(ordered-roots (if (and current-root (member current-root all-roots))
(cons current-root (remove current-root all-roots))
all-roots))
(repo-root (cond
((null ordered-roots)
(user-error "Not inside a Git repository"))
((= (length ordered-roots) 1)
(car ordered-roots))
(t
(completing-read "Select Git repository for context: "
ordered-roots
nil t nil nil (car ordered-roots))))))
(if (derived-mode-p 'dired-mode)
(let* ((all-marked (dired-get-marked-files))
(file-at-point (dired-get-filename nil t))
(has-marks (and all-marked
(not (and (= (length all-marked) 1)
file-at-point
(equal (car all-marked) file-at-point)))))
(targets (cond
(has-marks all-marked)
(file-at-point (list file-at-point))
(t nil))))
(unless targets
(user-error "No file or directory selected in Dired"))
(dolist (path targets)
(ai-code--store-context-entry repo-root path))
(message "Added context for %s: %s"
repo-root
(mapconcat #'identity targets ", ")))
(unless buffer-file-name
(user-error "Current buffer is not visiting a file"))
(let ((context
(if (use-region-p)
(let* ((start (region-beginning))
(end (region-end))
(start-line (line-number-at-pos (min start end)))
(end-line (line-number-at-pos (max start end))))
(format "%s#L%d-L%d" buffer-file-name start-line end-line))
(let ((function-name (when (fboundp 'which-function)
(which-function))))
(if (and function-name
(stringp function-name)
(not (string-empty-p function-name)))
(format "%s#%s" buffer-file-name function-name)
buffer-file-name)))))
(ai-code--store-context-entry repo-root context)
(message "Added context for %s: %s" repo-root context)))))
(defun ai-code-list-context ()
"Display stored context entries grouped by Git repository."
(interactive)
(if (= (hash-table-count ai-code--repo-context-info) 0)
(message "No stored context info.")
(let ((sections '()))
(maphash
(lambda (repo contexts)
(let ((entries (if contexts
(mapconcat (lambda (ctx)
(concat " - " ctx))
(reverse contexts)
"\n")
" (no entries)")))
(push (format "Repo: %s\n%s" repo entries) sections)))
ai-code--repo-context-info)
(message "%s" (string-join (nreverse sections) "\n\n")))))
(defun ai-code-clear-context (&optional arg)
"Clear stored context entries.
Without ARG, clear entries for the current Git repository.
With prefix ARG, clear all repositories."
(interactive "P")
(if arg
(progn
(clrhash ai-code--repo-context-info)
(message "Cleared all stored context info."))
(let ((repo-root (or (magit-toplevel)
(user-error "Not inside a Git repository"))))
(if (gethash repo-root ai-code--repo-context-info)
(progn
(remhash repo-root ai-code--repo-context-info)
(message "Cleared context info for %s." repo-root))
(message "No context info stored for %s." repo-root)))))
(defun ai-code-context-action (arg)
"Add or clear context entries depending on ARG.
Without prefix ARG, add context and immediately list all stored entries.
With prefix ARG \[universal-argument], clear context; clearing the
current repo or all repos is delegated to `ai-code-clear-context'."
(interactive "P")
(if arg
(call-interactively #'ai-code-clear-context)
(call-interactively #'ai-code-add-context)
(ai-code-list-context)))
(defun ai-code--format-repo-context-info ()
"Return formatted repository context string or nil.
Includes stored context entries for the current Git repository if available."
(when (and (boundp 'ai-code--repo-context-info)
ai-code--repo-context-info)
(let ((repo-root (condition-case nil
(magit-toplevel)
(error nil))))
(when repo-root
(let ((entries (gethash repo-root ai-code--repo-context-info)))
(when entries
(concat "\nStored repository context:\n"
(mapconcat (lambda (ctx)
(concat " - " ctx))
(reverse entries)
"\n"))))))))
;;;###autoload
(defun ai-code-toggle-current-buffer-dedicated (arg)
"Toggle the dedicated state of the current buffer's window.
When a window is dedicated, Emacs will not automatically reuse it for
displaying other buffers. With prefix ARG \[universal-argument],
toggle dedication for every window in the current frame."
;; DONE: if C-u pressed, toggle dedicate to all buffers inside current window
(interactive "P")
(let* ((targets (if arg
(window-list nil 'no-minibuf)
(list (selected-window))))
(results '()))
(dolist (window targets)
(let ((buffer (window-buffer window)))
(when (buffer-file-name buffer)
(let ((new-status (not (window-dedicated-p window))))
(set-window-dedicated-p window new-status)
(push (format "%s: %s" (buffer-name buffer) (if new-status "dedicated" "free")) results)))))
;; DONE: show each affected buffer name and dedicate status
(if results
(message "Window dedication updated: %s" (mapconcat #'identity (nreverse results) ", "))
(message "No file buffers found in target windows"))))
(provide 'ai-code-file)
;;; ai-code-file.el ends here
You can’t perform that action at this time.
