diff --git a/crates/fresh-core/src/api.rs b/crates/fresh-core/src/api.rs index 5492955c4..8038da377 100644 --- a/crates/fresh-core/src/api.rs +++ b/crates/fresh-core/src/api.rs @@ -1401,6 +1401,9 @@ pub enum PluginCommand { read_only: bool, /// When true, unbound character keys dispatch as `mode_text_input:`. allow_text_input: bool, + /// When true, keys not bound by this mode fall through to the Normal + /// context (motion, selection, copy) instead of being dropped. + inherit_normal_bindings: bool, /// Name of the plugin that defined this mode (for attribution) plugin_name: Option, }, @@ -3031,6 +3034,7 @@ impl PluginApi { bindings, read_only, allow_text_input, + inherit_normal_bindings: false, plugin_name: None, }) } @@ -4082,7 +4086,7 @@ mod tests { false, ), PluginCommand::DefineMode { - name, bindings, read_only, allow_text_input, plugin_name + name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name } if name == "m" && bindings.len() == 1 @@ -4090,6 +4094,7 @@ mod tests { && bindings[0].1 == "move_down" && read_only && !allow_text_input + && !inherit_normal_bindings && plugin_name.is_none() ); diff --git a/crates/fresh-editor/plugins/audit_mode.i18n.json b/crates/fresh-editor/plugins/audit_mode.i18n.json index 12e334eb8..90cbaf407 100644 --- a/crates/fresh-editor/plugins/audit_mode.i18n.json +++ b/crates/fresh-editor/plugins/audit_mode.i18n.json @@ -6,6 +6,17 @@ "cmd.stop_review_diff_desc": "Stop the review session", "cmd.refresh_review_diff": "Refresh Review Diff", "cmd.refresh_review_diff_desc": "Refresh the list of changes", + "cmd.review_branch": "Review PR Branch", + "cmd.review_branch_desc": "Review all commits on the current branch against a base ref", + "cmd.stop_review_branch": "Stop Review Branch", + "cmd.stop_review_branch_desc": "Close the PR-branch review panel", + "cmd.refresh_review_branch": "Refresh Review Branch", + "cmd.refresh_review_branch_desc": "Re-fetch the commit list for the current base ref", + "prompt.branch_base": "Base ref to compare against (default: main):", + "status.review_branch_ready": "Reviewing %{count} commits in %{base}..HEAD", + "status.review_branch_empty": "No commits in %{base}..HEAD — nothing to review.", + "panel.review_branch_header": "Commits (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: navigate | Enter: focus detail | r: refresh | q: close", "cmd.side_by_side_diff": "Side-by-Side Diff", "cmd.side_by_side_diff_desc": "Show side-by-side diff for current file", "cmd.add_comment": "Review: Add Comment", @@ -67,10 +78,23 @@ "cmd.stop_review_diff_desc": "Zastavit relaci revize", "cmd.refresh_review_diff": "Obnovit rozdily", "cmd.refresh_review_diff_desc": "Obnovit seznam zmen", + "cmd.review_branch": "Revidovat větev PR", + "cmd.review_branch_desc": "Zkontrolovat všechny commity v aktuální větvi oproti základnímu refu", + "cmd.stop_review_branch": "Ukončit revizi větve", + "cmd.stop_review_branch_desc": "Zavřít panel revize větve PR", + "cmd.refresh_review_branch": "Obnovit větev k revizi", + "cmd.refresh_review_branch_desc": "Znovu načíst seznam commitů pro aktuální základní ref", + "prompt.branch_base": "Základní ref pro porovnání (výchozí: main):", + "status.review_branch_ready": "Kontroluje se %{count} commitů v %{base}..HEAD", + "status.review_branch_empty": "Žádné commity v %{base}..HEAD — není co revidovat.", + "panel.review_branch_header": "Commity (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: navigace | Enter: detail | r: obnovit | q: zavřít", "cmd.side_by_side_diff": "Rozdily vedle sebe", "cmd.side_by_side_diff_desc": "Zobrazit rozdily aktualniho souboru vedle sebe", "cmd.add_comment": "Revize: Pridat komentar", "cmd.add_comment_desc": "Pridat komentar k revizi aktualniho bloku", + "cmd.edit_note": "Revize: Upravit poznámku", + "cmd.edit_note_desc": "Upravit poznámku relace", "cmd.export_markdown": "Revize: Exportovat do Markdown", "cmd.export_markdown_desc": "Exportovat revizi do .review/session.md", "cmd.export_json": "Revize: Exportovat do JSON", @@ -83,9 +107,14 @@ "status.failed_new_version": "Nepodarilo se nacist novou verzi souboru", "status.diff_summary": "Rozdily vedle sebe: +%{added} -%{removed} ~%{modified} | 'q' pro navrat", "status.no_hunk_selected": "Zadny blok nevybran pro komentar", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Komentar pridan k %{line}", "status.comment_cancelled": "Komentar zrusen", + "status.overall_comment_added": "Poznámka přidána", "status.exported": "Revize exportovana do %{path}", + "status.hunk_staged": "Blok připraven", + "status.hunk_unstaged": "Blok odstraněn z přípravy", + "status.hunk_discarded": "Blok zahozen", "status.generating": "Generuji proud revize rozdilu...", "status.review_summary": "Revize rozdilu: %{count} bloku | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Rezim revize rozdilu zastaven.", @@ -94,23 +123,15 @@ "status.no_changes": "V tomto souboru nejsou zadne zmeny", "status.failed_old_new_file": "Nepodarilo se nacist starou verzi souboru (soubor muze byt novy)", "prompt.comment": "Komentar k %{line}: ", - "panel.no_changes": "Zadne zmeny k revizi.", - "section.staged": "Připravené změny", - "section.unstaged": "Upravené (nepřipravené)", - "section.untracked": "Nesledované soubory", - "debug.loaded": "Plugin revize rozdilu nacten s podporou komentaru", - "cmd.edit_note": "Revize: Upravit poznámku", - "cmd.edit_note_desc": "Upravit poznámku relace", - "prompt.discard_hunk": "Zahodit tento blok v \"%{file}\"? Tuto akci nelze vrátit.", "prompt.edit_comment": "Upravit komentář na %{line}: ", "prompt.overall_comment": "Poznámka: ", - "status.hunk_discarded": "Blok zahozen", - "status.hunk_staged": "Blok připraven", - "status.hunk_unstaged": "Blok odstraněn z přípravy", - "status.overall_comment_added": "Poznámka přidána", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Zahodit tento blok v \"%{file}\"? Tuto akci nelze vrátit.", + "panel.no_changes": "Zadne zmeny k revizi.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Připravené změny", + "section.unstaged": "Upravené (nepřipravené)", + "section.untracked": "Nesledované soubory", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -119,7 +140,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin revize rozdilu nacten s podporou komentaru", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "de": { "cmd.review_diff": "Unterschiede prufen", @@ -128,10 +150,23 @@ "cmd.stop_review_diff_desc": "Review-Sitzung beenden", "cmd.refresh_review_diff": "Unterschiede aktualisieren", "cmd.refresh_review_diff_desc": "Liste der Anderungen aktualisieren", + "cmd.review_branch": "PR-Zweig überprüfen", + "cmd.review_branch_desc": "Alle Commits im aktuellen Zweig gegen eine Basis-Ref überprüfen", + "cmd.stop_review_branch": "Überprüfung beenden", + "cmd.stop_review_branch_desc": "Das Panel zur Überprüfung des PR-Zweigs schließen", + "cmd.refresh_review_branch": "Überprüfungszweig aktualisieren", + "cmd.refresh_review_branch_desc": "Commit-Liste für die aktuelle Basis-Ref neu laden", + "prompt.branch_base": "Basis-Ref zum Vergleichen (Standard: main):", + "status.review_branch_ready": "%{count} Commits in %{base}..HEAD werden überprüft", + "status.review_branch_empty": "Keine Commits in %{base}..HEAD — nichts zu überprüfen.", + "panel.review_branch_header": "Commits (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: Navigation | Enter: Detail | r: Aktualisieren | q: Schließen", "cmd.side_by_side_diff": "Nebeneinander-Ansicht", "cmd.side_by_side_diff_desc": "Unterschiede der aktuellen Datei nebeneinander anzeigen", "cmd.add_comment": "Review: Kommentar hinzufugen", "cmd.add_comment_desc": "Einen Review-Kommentar zum aktuellen Block hinzufugen", + "cmd.edit_note": "Review: Notiz bearbeiten", + "cmd.edit_note_desc": "Sitzungsnotiz bearbeiten", "cmd.export_markdown": "Review: Als Markdown exportieren", "cmd.export_markdown_desc": "Review nach .review/session.md exportieren", "cmd.export_json": "Review: Als JSON exportieren", @@ -144,9 +179,14 @@ "status.failed_new_version": "Neue Dateiversion konnte nicht geladen werden", "status.diff_summary": "Nebeneinander-Ansicht: +%{added} -%{removed} ~%{modified} | 'q' zum Zuruckkehren", "status.no_hunk_selected": "Kein Block fur Kommentar ausgewahlt", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Kommentar zu %{line} hinzugefugt", "status.comment_cancelled": "Kommentar abgebrochen", + "status.overall_comment_added": "Notiz hinzugefügt", "status.exported": "Review exportiert nach %{path}", + "status.hunk_staged": "Block bereitgestellt", + "status.hunk_unstaged": "Block aus Bereitstellung entfernt", + "status.hunk_discarded": "Block verworfen", "status.generating": "Review-Diff-Stream wird generiert...", "status.review_summary": "Review: %{count} Blocke | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Review-Diff-Modus beendet.", @@ -155,23 +195,15 @@ "status.no_changes": "Keine Anderungen in dieser Datei", "status.failed_old_new_file": "Alte Dateiversion konnte nicht geladen werden (Datei ist moglicherweise neu)", "prompt.comment": "Kommentar zu %{line}: ", - "panel.no_changes": "Keine Anderungen zu prufen.", - "section.staged": "Bereitgestellte Änderungen", - "section.unstaged": "Geändert (nicht bereitgestellt)", - "section.untracked": "Nicht verfolgte Dateien", - "debug.loaded": "Review-Diff-Plugin mit Kommentarunterstutzung geladen", - "cmd.edit_note": "Review: Notiz bearbeiten", - "cmd.edit_note_desc": "Sitzungsnotiz bearbeiten", - "prompt.discard_hunk": "Diesen Block in \"%{file}\" verwerfen? Dies kann nicht rückgängig gemacht werden.", "prompt.edit_comment": "Kommentar bei %{line} bearbeiten: ", "prompt.overall_comment": "Notiz: ", - "status.hunk_discarded": "Block verworfen", - "status.hunk_staged": "Block bereitgestellt", - "status.hunk_unstaged": "Block aus Bereitstellung entfernt", - "status.overall_comment_added": "Notiz hinzugefügt", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Diesen Block in \"%{file}\" verwerfen? Dies kann nicht rückgängig gemacht werden.", + "panel.no_changes": "Keine Anderungen zu prufen.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Bereitgestellte Änderungen", + "section.unstaged": "Geändert (nicht bereitgestellt)", + "section.untracked": "Nicht verfolgte Dateien", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -180,7 +212,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Review-Diff-Plugin mit Kommentarunterstutzung geladen", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "es": { "cmd.review_diff": "Revisar Diferencias", @@ -189,10 +222,23 @@ "cmd.stop_review_diff_desc": "Detener la sesion de revision", "cmd.refresh_review_diff": "Actualizar Diferencias", "cmd.refresh_review_diff_desc": "Actualizar la lista de cambios", + "cmd.review_branch": "Revisar rama del PR", + "cmd.review_branch_desc": "Revisar todos los commits de la rama actual contra una ref base", + "cmd.stop_review_branch": "Detener revisión de rama", + "cmd.stop_review_branch_desc": "Cerrar el panel de revisión de la rama del PR", + "cmd.refresh_review_branch": "Actualizar rama de revisión", + "cmd.refresh_review_branch_desc": "Volver a obtener la lista de commits para la ref base actual", + "prompt.branch_base": "Ref base para comparar (por defecto: main):", + "status.review_branch_ready": "Revisando %{count} commits en %{base}..HEAD", + "status.review_branch_empty": "No hay commits en %{base}..HEAD — nada que revisar.", + "panel.review_branch_header": "Commits (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: navegar | Enter: detalle | r: actualizar | q: cerrar", "cmd.side_by_side_diff": "Diferencias Lado a Lado", "cmd.side_by_side_diff_desc": "Mostrar diferencias lado a lado del archivo actual", "cmd.add_comment": "Revision: Agregar Comentario", "cmd.add_comment_desc": "Agregar un comentario de revision al bloque actual", + "cmd.edit_note": "Revisión: Editar nota", + "cmd.edit_note_desc": "Editar la nota de la sesión", "cmd.export_markdown": "Revision: Exportar a Markdown", "cmd.export_markdown_desc": "Exportar revision a .review/session.md", "cmd.export_json": "Revision: Exportar a JSON", @@ -205,9 +251,14 @@ "status.failed_new_version": "Error al cargar version nueva del archivo", "status.diff_summary": "Diferencias lado a lado: +%{added} -%{removed} ~%{modified} | 'q' para volver", "status.no_hunk_selected": "Ningun bloque seleccionado para comentar", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Comentario agregado a %{line}", "status.comment_cancelled": "Comentario cancelado", + "status.overall_comment_added": "Nota añadida", "status.exported": "Revision exportada a %{path}", + "status.hunk_staged": "Bloque preparado", + "status.hunk_unstaged": "Bloque retirado de preparación", + "status.hunk_discarded": "Bloque descartado", "status.generating": "Generando flujo de revision de diferencias...", "status.review_summary": "Revision: %{count} bloques | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Modo de revision de diferencias detenido.", @@ -216,23 +267,15 @@ "status.no_changes": "No hay cambios en este archivo", "status.failed_old_new_file": "Error al cargar version anterior (el archivo puede ser nuevo)", "prompt.comment": "Comentario en %{line}: ", - "panel.no_changes": "No hay cambios para revisar.", - "section.staged": "Cambios preparados", - "section.unstaged": "Modificados (sin preparar)", - "section.untracked": "Archivos sin rastrear", - "debug.loaded": "Plugin de revision de diferencias cargado con soporte de comentarios", - "cmd.edit_note": "Revisión: Editar nota", - "cmd.edit_note_desc": "Editar la nota de la sesión", - "prompt.discard_hunk": "¿Descartar este bloque en \"%{file}\"? Esta acción no se puede deshacer.", "prompt.edit_comment": "Editar comentario en %{line}: ", "prompt.overall_comment": "Nota: ", - "status.hunk_discarded": "Bloque descartado", - "status.hunk_staged": "Bloque preparado", - "status.hunk_unstaged": "Bloque retirado de preparación", - "status.overall_comment_added": "Nota añadida", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "¿Descartar este bloque en \"%{file}\"? Esta acción no se puede deshacer.", + "panel.no_changes": "No hay cambios para revisar.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Cambios preparados", + "section.unstaged": "Modificados (sin preparar)", + "section.untracked": "Archivos sin rastrear", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -241,7 +284,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin de revision de diferencias cargado con soporte de comentarios", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "fr": { "cmd.review_diff": "Revoir les Differences", @@ -250,10 +294,23 @@ "cmd.stop_review_diff_desc": "Arreter la session de revue", "cmd.refresh_review_diff": "Actualiser les Differences", "cmd.refresh_review_diff_desc": "Actualiser la liste des modifications", + "cmd.review_branch": "Revoir la branche de PR", + "cmd.review_branch_desc": "Revoir tous les commits de la branche actuelle par rapport à une ref de base", + "cmd.stop_review_branch": "Arrêter la revue de branche", + "cmd.stop_review_branch_desc": "Fermer le panneau de revue de branche PR", + "cmd.refresh_review_branch": "Actualiser la branche de revue", + "cmd.refresh_review_branch_desc": "Récupérer à nouveau la liste des commits pour la ref de base actuelle", + "prompt.branch_base": "Ref de base pour la comparaison (par défaut : main) :", + "status.review_branch_ready": "Revue de %{count} commits dans %{base}..HEAD", + "status.review_branch_empty": "Aucun commit dans %{base}..HEAD — rien à revoir.", + "panel.review_branch_header": "Commits (%{base}..HEAD)", + "panel.review_branch_footer": "j/k : naviguer | Entrée : détail | r : actualiser | q : fermer", "cmd.side_by_side_diff": "Differences Cote a Cote", "cmd.side_by_side_diff_desc": "Afficher les differences du fichier actuel cote a cote", "cmd.add_comment": "Revue: Ajouter un Commentaire", "cmd.add_comment_desc": "Ajouter un commentaire de revue au bloc actuel", + "cmd.edit_note": "Revue : Modifier la note", + "cmd.edit_note_desc": "Modifier la note de session", "cmd.export_markdown": "Revue: Exporter en Markdown", "cmd.export_markdown_desc": "Exporter la revue vers .review/session.md", "cmd.export_json": "Revue: Exporter en JSON", @@ -266,9 +323,14 @@ "status.failed_new_version": "Echec du chargement de la nouvelle version du fichier", "status.diff_summary": "Differences cote a cote: +%{added} -%{removed} ~%{modified} | 'q' pour revenir", "status.no_hunk_selected": "Aucun bloc selectionne pour le commentaire", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Commentaire ajoute a %{line}", "status.comment_cancelled": "Commentaire annule", + "status.overall_comment_added": "Note ajoutée", "status.exported": "Revue exportee vers %{path}", + "status.hunk_staged": "Bloc indexé", + "status.hunk_unstaged": "Bloc retiré de l'index", + "status.hunk_discarded": "Bloc supprimé", "status.generating": "Generation du flux de revue des differences...", "status.review_summary": "Revue: %{count} blocs | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Mode de revue des differences arrete.", @@ -277,23 +339,15 @@ "status.no_changes": "Aucune modification dans ce fichier", "status.failed_old_new_file": "Echec du chargement de l'ancienne version (le fichier est peut-etre nouveau)", "prompt.comment": "Commentaire sur %{line}: ", - "panel.no_changes": "Aucune modification a revoir.", - "section.staged": "Modifications indexées", - "section.unstaged": "Modifiés (non indexés)", - "section.untracked": "Fichiers non suivis", - "debug.loaded": "Plugin de revue des differences charge avec prise en charge des commentaires", - "cmd.edit_note": "Revue : Modifier la note", - "cmd.edit_note_desc": "Modifier la note de session", - "prompt.discard_hunk": "Supprimer ce bloc dans \"%{file}\" ? Cette action est irréversible.", "prompt.edit_comment": "Modifier le commentaire sur %{line} : ", "prompt.overall_comment": "Note : ", - "status.hunk_discarded": "Bloc supprimé", - "status.hunk_staged": "Bloc indexé", - "status.hunk_unstaged": "Bloc retiré de l'index", - "status.overall_comment_added": "Note ajoutée", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Supprimer ce bloc dans \"%{file}\" ? Cette action est irréversible.", + "panel.no_changes": "Aucune modification a revoir.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Modifications indexées", + "section.unstaged": "Modifiés (non indexés)", + "section.untracked": "Fichiers non suivis", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -302,7 +356,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin de revue des differences charge avec prise en charge des commentaires", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "it": { "cmd.review_diff": "Revisiona differenze", @@ -311,10 +366,23 @@ "cmd.stop_review_diff_desc": "Interrompi la sessione di revisione", "cmd.refresh_review_diff": "Aggiorna differenze", "cmd.refresh_review_diff_desc": "Aggiorna l'elenco delle modifiche", + "cmd.review_branch": "Revisiona ramo PR", + "cmd.review_branch_desc": "Revisiona tutti i commit del ramo corrente rispetto a un ref base", + "cmd.stop_review_branch": "Interrompi revisione ramo", + "cmd.stop_review_branch_desc": "Chiudi il pannello di revisione del ramo PR", + "cmd.refresh_review_branch": "Aggiorna ramo di revisione", + "cmd.refresh_review_branch_desc": "Ricarica la lista dei commit per il ref base corrente", + "prompt.branch_base": "Ref base per il confronto (predefinito: main):", + "status.review_branch_ready": "Revisione di %{count} commit in %{base}..HEAD", + "status.review_branch_empty": "Nessun commit in %{base}..HEAD — niente da revisionare.", + "panel.review_branch_header": "Commit (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: naviga | Invio: dettaglio | r: aggiorna | q: chiudi", "cmd.side_by_side_diff": "Diff affiancato", "cmd.side_by_side_diff_desc": "Mostra diff affiancato per il file corrente", "cmd.add_comment": "Revisione: Aggiungi commento", "cmd.add_comment_desc": "Aggiungi un commento di revisione al blocco corrente", + "cmd.edit_note": "Revisione: Modifica nota", + "cmd.edit_note_desc": "Modifica la nota della sessione", "cmd.export_markdown": "Revisione: Esporta in Markdown", "cmd.export_markdown_desc": "Esporta la revisione in .review/session.md", "cmd.export_json": "Revisione: Esporta in JSON", @@ -327,9 +395,14 @@ "status.failed_new_version": "Impossibile caricare la nuova versione del file", "status.diff_summary": "Diff affiancato: +%{added} -%{removed} ~%{modified} | 'q' per tornare", "status.no_hunk_selected": "Nessun blocco selezionato per il commento", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Commento aggiunto a %{line}", "status.comment_cancelled": "Commento annullato", + "status.overall_comment_added": "Nota aggiunta", "status.exported": "Revisione esportata in %{path}", + "status.hunk_staged": "Blocco preparato", + "status.hunk_unstaged": "Blocco rimosso dalla preparazione", + "status.hunk_discarded": "Blocco eliminato", "status.generating": "Generazione stream differenze revisione...", "status.review_summary": "Differenze revisione: %{count} blocchi | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Modalità Revisione differenze interrotta.", @@ -338,23 +411,15 @@ "status.no_changes": "Nessuna modifica in questo file", "status.failed_old_new_file": "Impossibile caricare la vecchia versione (il file potrebbe essere nuovo)", "prompt.comment": "Commento su %{line}: ", - "panel.no_changes": "Nessuna modifica da revisionare.", - "section.staged": "Modifiche preparate", - "section.unstaged": "Modificati (non preparati)", - "section.untracked": "File non tracciati", - "debug.loaded": "Plugin Revisione differenze caricato con supporto commenti", - "cmd.edit_note": "Revisione: Modifica nota", - "cmd.edit_note_desc": "Modifica la nota della sessione", - "prompt.discard_hunk": "Eliminare questo blocco in \"%{file}\"? Questa azione non può essere annullata.", "prompt.edit_comment": "Modifica commento su %{line}: ", "prompt.overall_comment": "Nota: ", - "status.hunk_discarded": "Blocco eliminato", - "status.hunk_staged": "Blocco preparato", - "status.hunk_unstaged": "Blocco rimosso dalla preparazione", - "status.overall_comment_added": "Nota aggiunta", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Eliminare questo blocco in \"%{file}\"? Questa azione non può essere annullata.", + "panel.no_changes": "Nessuna modifica da revisionare.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Modifiche preparate", + "section.unstaged": "Modificati (non preparati)", + "section.untracked": "File non tracciati", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -363,7 +428,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin Revisione differenze caricato con supporto commenti", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "ja": { "cmd.review_diff": "差分レビュー", @@ -372,10 +438,23 @@ "cmd.stop_review_diff_desc": "レビューセッションを停止", "cmd.refresh_review_diff": "差分を更新", "cmd.refresh_review_diff_desc": "変更リストを更新", + "cmd.review_branch": "PR ブランチをレビュー", + "cmd.review_branch_desc": "現在のブランチのすべてのコミットをベース ref に対してレビュー", + "cmd.stop_review_branch": "レビューを停止", + "cmd.stop_review_branch_desc": "PR ブランチレビューパネルを閉じる", + "cmd.refresh_review_branch": "レビューブランチを更新", + "cmd.refresh_review_branch_desc": "現在のベース ref のコミットリストを再取得", + "prompt.branch_base": "比較対象のベース ref (デフォルト: main):", + "status.review_branch_ready": "%{base}..HEAD の %{count} 件のコミットをレビュー中", + "status.review_branch_empty": "%{base}..HEAD にコミットはありません — レビューする対象がありません。", + "panel.review_branch_header": "コミット (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: 移動 | Enter: 詳細 | r: 更新 | q: 閉じる", "cmd.side_by_side_diff": "サイドバイサイド差分", "cmd.side_by_side_diff_desc": "現在のファイルの差分を横並びで表示", "cmd.add_comment": "レビュー: コメント追加", "cmd.add_comment_desc": "現在のハンクにレビューコメントを追加", + "cmd.edit_note": "レビュー: メモを編集", + "cmd.edit_note_desc": "セッションメモを編集", "cmd.export_markdown": "レビュー: Markdownにエクスポート", "cmd.export_markdown_desc": "レビューを.review/session.mdにエクスポート", "cmd.export_json": "レビュー: JSONにエクスポート", @@ -388,9 +467,14 @@ "status.failed_new_version": "新しいファイルバージョンの読み込みに失敗しました", "status.diff_summary": "サイドバイサイド差分: +%{added} -%{removed} ~%{modified} | 'q'で戻る", "status.no_hunk_selected": "コメントするハンクが選択されていません", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "%{line}にコメントを追加しました", "status.comment_cancelled": "コメントがキャンセルされました", + "status.overall_comment_added": "メモを追加しました", "status.exported": "レビューを%{path}にエクスポートしました", + "status.hunk_staged": "ハンクをステージしました", + "status.hunk_unstaged": "ハンクをアンステージしました", + "status.hunk_discarded": "ハンクを破棄しました", "status.generating": "レビュー差分ストリームを生成中...", "status.review_summary": "レビュー差分: %{count}ハンク | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "レビュー差分モードを停止しました。", @@ -399,23 +483,15 @@ "status.no_changes": "このファイルに変更はありません", "status.failed_old_new_file": "古いファイルバージョンの読み込みに失敗しました(新規ファイルの可能性があります)", "prompt.comment": "%{line}へのコメント: ", - "panel.no_changes": "レビューする変更がありません。", - "section.staged": "ステージ済みの変更", - "section.unstaged": "変更あり(未ステージ)", - "section.untracked": "未追跡ファイル", - "debug.loaded": "レビュー差分プラグインがコメントサポート付きで読み込まれました", - "cmd.edit_note": "レビュー: メモを編集", - "cmd.edit_note_desc": "セッションメモを編集", - "prompt.discard_hunk": "\"%{file}\" のこのハンクを破棄しますか?この操作は元に戻せません。", "prompt.edit_comment": "%{line} のコメントを編集: ", "prompt.overall_comment": "メモ: ", - "status.hunk_discarded": "ハンクを破棄しました", - "status.hunk_staged": "ハンクをステージしました", - "status.hunk_unstaged": "ハンクをアンステージしました", - "status.overall_comment_added": "メモを追加しました", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "\"%{file}\" のこのハンクを破棄しますか?この操作は元に戻せません。", + "panel.no_changes": "レビューする変更がありません。", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "ステージ済みの変更", + "section.unstaged": "変更あり(未ステージ)", + "section.untracked": "未追跡ファイル", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -424,7 +500,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "レビュー差分プラグインがコメントサポート付きで読み込まれました", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "ko": { "cmd.review_diff": "차이점 검토", @@ -433,10 +510,23 @@ "cmd.stop_review_diff_desc": "리뷰 세션 중지", "cmd.refresh_review_diff": "차이점 새로고침", "cmd.refresh_review_diff_desc": "변경 목록 새로고침", + "cmd.review_branch": "PR 브랜치 리뷰", + "cmd.review_branch_desc": "현재 브랜치의 모든 커밋을 기준 ref와 비교해 리뷰", + "cmd.stop_review_branch": "리뷰 중지", + "cmd.stop_review_branch_desc": "PR 브랜치 리뷰 패널 닫기", + "cmd.refresh_review_branch": "리뷰 브랜치 새로 고침", + "cmd.refresh_review_branch_desc": "현재 기준 ref의 커밋 목록을 다시 가져오기", + "prompt.branch_base": "비교할 기준 ref (기본값: main):", + "status.review_branch_ready": "%{base}..HEAD의 %{count}개 커밋을 리뷰 중", + "status.review_branch_empty": "%{base}..HEAD에 커밋이 없습니다 — 리뷰할 내용이 없습니다.", + "panel.review_branch_header": "커밋 (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: 이동 | Enter: 상세 | r: 새로 고침 | q: 닫기", "cmd.side_by_side_diff": "나란히 비교", "cmd.side_by_side_diff_desc": "현재 파일의 차이점을 나란히 표시", "cmd.add_comment": "검토: 코멘트 추가", "cmd.add_comment_desc": "현재 헝크에 리뷰 코멘트 추가", + "cmd.edit_note": "검토: 메모 편집", + "cmd.edit_note_desc": "세션 메모 편집", "cmd.export_markdown": "검토: Markdown으로 내보내기", "cmd.export_markdown_desc": "리뷰를 .review/session.md로 내보내기", "cmd.export_json": "검토: JSON으로 내보내기", @@ -449,9 +539,14 @@ "status.failed_new_version": "새 파일 버전 로드 실패", "status.diff_summary": "나란히 비교: +%{added} -%{removed} ~%{modified} | 'q'로 돌아가기", "status.no_hunk_selected": "코멘트할 헝크가 선택되지 않았습니다", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "%{line}에 코멘트가 추가되었습니다", "status.comment_cancelled": "코멘트가 취소되었습니다", + "status.overall_comment_added": "메모가 추가되었습니다", "status.exported": "리뷰가 %{path}로 내보내졌습니다", + "status.hunk_staged": "헝크가 스테이지되었습니다", + "status.hunk_unstaged": "헝크가 언스테이지되었습니다", + "status.hunk_discarded": "헝크가 삭제되었습니다", "status.generating": "리뷰 차이점 스트림 생성 중...", "status.review_summary": "리뷰 차이점: %{count}개 헝크 | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "리뷰 차이점 모드가 중지되었습니다.", @@ -460,23 +555,15 @@ "status.no_changes": "이 파일에 변경 사항이 없습니다", "status.failed_old_new_file": "이전 파일 버전 로드 실패 (새 파일일 수 있음)", "prompt.comment": "%{line}에 대한 코멘트: ", - "panel.no_changes": "검토할 변경 사항이 없습니다.", - "section.staged": "스테이지된 변경사항", - "section.unstaged": "수정됨 (스테이지 안됨)", - "section.untracked": "추적되지 않는 파일", - "debug.loaded": "리뷰 차이점 플러그인이 코멘트 지원과 함께 로드되었습니다", - "cmd.edit_note": "검토: 메모 편집", - "cmd.edit_note_desc": "세션 메모 편집", - "prompt.discard_hunk": "\"%{file}\"의 이 헝크를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "prompt.edit_comment": "%{line}의 코멘트 편집: ", "prompt.overall_comment": "메모: ", - "status.hunk_discarded": "헝크가 삭제되었습니다", - "status.hunk_staged": "헝크가 스테이지되었습니다", - "status.hunk_unstaged": "헝크가 언스테이지되었습니다", - "status.overall_comment_added": "메모가 추가되었습니다", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "\"%{file}\"의 이 헝크를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "panel.no_changes": "검토할 변경 사항이 없습니다.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "스테이지된 변경사항", + "section.unstaged": "수정됨 (스테이지 안됨)", + "section.untracked": "추적되지 않는 파일", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -485,7 +572,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "리뷰 차이점 플러그인이 코멘트 지원과 함께 로드되었습니다", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "pt-BR": { "cmd.review_diff": "Revisar Diferencas", @@ -494,10 +582,23 @@ "cmd.stop_review_diff_desc": "Parar a sessao de revisao", "cmd.refresh_review_diff": "Atualizar Diferencas", "cmd.refresh_review_diff_desc": "Atualizar a lista de alteracoes", + "cmd.review_branch": "Revisar branch do PR", + "cmd.review_branch_desc": "Revisar todos os commits da branch atual contra uma ref base", + "cmd.stop_review_branch": "Parar revisão da branch", + "cmd.stop_review_branch_desc": "Fechar o painel de revisão da branch do PR", + "cmd.refresh_review_branch": "Atualizar branch de revisão", + "cmd.refresh_review_branch_desc": "Recarregar a lista de commits para a ref base atual", + "prompt.branch_base": "Ref base para comparação (padrão: main):", + "status.review_branch_ready": "Revisando %{count} commits em %{base}..HEAD", + "status.review_branch_empty": "Sem commits em %{base}..HEAD — nada para revisar.", + "panel.review_branch_header": "Commits (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: navegar | Enter: detalhe | r: atualizar | q: fechar", "cmd.side_by_side_diff": "Diferencas Lado a Lado", "cmd.side_by_side_diff_desc": "Mostrar diferencas do arquivo atual lado a lado", "cmd.add_comment": "Revisao: Adicionar Comentario", "cmd.add_comment_desc": "Adicionar um comentario de revisao ao bloco atual", + "cmd.edit_note": "Revisão: Editar nota", + "cmd.edit_note_desc": "Editar a nota da sessão", "cmd.export_markdown": "Revisao: Exportar para Markdown", "cmd.export_markdown_desc": "Exportar revisao para .review/session.md", "cmd.export_json": "Revisao: Exportar para JSON", @@ -510,9 +611,14 @@ "status.failed_new_version": "Falha ao carregar versao nova do arquivo", "status.diff_summary": "Diferencas lado a lado: +%{added} -%{removed} ~%{modified} | 'q' para voltar", "status.no_hunk_selected": "Nenhum bloco selecionado para comentario", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Comentario adicionado em %{line}", "status.comment_cancelled": "Comentario cancelado", + "status.overall_comment_added": "Nota adicionada", "status.exported": "Revisao exportada para %{path}", + "status.hunk_staged": "Bloco preparado", + "status.hunk_unstaged": "Bloco removido da preparação", + "status.hunk_discarded": "Bloco descartado", "status.generating": "Gerando fluxo de revisao de diferencas...", "status.review_summary": "Revisao: %{count} blocos | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Modo de revisao de diferencas parado.", @@ -521,23 +627,15 @@ "status.no_changes": "Sem alteracoes neste arquivo", "status.failed_old_new_file": "Falha ao carregar versao antiga (arquivo pode ser novo)", "prompt.comment": "Comentario em %{line}: ", - "panel.no_changes": "Sem alteracoes para revisar.", - "section.staged": "Alterações preparadas", - "section.unstaged": "Modificados (não preparados)", - "section.untracked": "Arquivos não rastreados", - "debug.loaded": "Plugin de revisao de diferencas carregado com suporte a comentarios", - "cmd.edit_note": "Revisão: Editar nota", - "cmd.edit_note_desc": "Editar a nota da sessão", - "prompt.discard_hunk": "Descartar este bloco em \"%{file}\"? Esta ação não pode ser desfeita.", "prompt.edit_comment": "Editar comentário em %{line}: ", "prompt.overall_comment": "Nota: ", - "status.hunk_discarded": "Bloco descartado", - "status.hunk_staged": "Bloco preparado", - "status.hunk_unstaged": "Bloco removido da preparação", - "status.overall_comment_added": "Nota adicionada", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Descartar este bloco em \"%{file}\"? Esta ação não pode ser desfeita.", + "panel.no_changes": "Sem alteracoes para revisar.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Alterações preparadas", + "section.unstaged": "Modificados (não preparados)", + "section.untracked": "Arquivos não rastreados", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -546,7 +644,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin de revisao de diferencas carregado com suporte a comentarios", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "ru": { "cmd.review_diff": "Просмотр изменений", @@ -555,10 +654,23 @@ "cmd.stop_review_diff_desc": "Остановить сеанс ревью", "cmd.refresh_review_diff": "Обновить изменения", "cmd.refresh_review_diff_desc": "Обновить список изменений", + "cmd.review_branch": "Ревью ветки PR", + "cmd.review_branch_desc": "Просмотреть все коммиты текущей ветки относительно базового ref", + "cmd.stop_review_branch": "Остановить ревью ветки", + "cmd.stop_review_branch_desc": "Закрыть панель ревью ветки PR", + "cmd.refresh_review_branch": "Обновить ветку для ревью", + "cmd.refresh_review_branch_desc": "Перезагрузить список коммитов для текущего базового ref", + "prompt.branch_base": "Базовый ref для сравнения (по умолчанию: main):", + "status.review_branch_ready": "Ревью %{count} коммитов в %{base}..HEAD", + "status.review_branch_empty": "Нет коммитов в %{base}..HEAD — нечего просматривать.", + "panel.review_branch_header": "Коммиты (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: навигация | Enter: детали | r: обновить | q: закрыть", "cmd.side_by_side_diff": "Сравнение бок о бок", "cmd.side_by_side_diff_desc": "Показать изменения текущего файла бок о бок", "cmd.add_comment": "Ревью: Добавить комментарий", "cmd.add_comment_desc": "Добавить комментарий к текущему блоку", + "cmd.edit_note": "Ревью: Редактировать заметку", + "cmd.edit_note_desc": "Редактировать заметку сессии", "cmd.export_markdown": "Ревью: Экспорт в Markdown", "cmd.export_markdown_desc": "Экспортировать ревью в .review/session.md", "cmd.export_json": "Ревью: Экспорт в JSON", @@ -571,9 +683,14 @@ "status.failed_new_version": "Не удалось загрузить новую версию файла", "status.diff_summary": "Сравнение бок о бок: +%{added} -%{removed} ~%{modified} | 'q' для возврата", "status.no_hunk_selected": "Блок для комментария не выбран", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Комментарий добавлен к %{line}", "status.comment_cancelled": "Комментарий отменен", + "status.overall_comment_added": "Заметка добавлена", "status.exported": "Ревью экспортировано в %{path}", + "status.hunk_staged": "Блок подготовлен", + "status.hunk_unstaged": "Блок убран из подготовки", + "status.hunk_discarded": "Блок отброшен", "status.generating": "Генерация потока ревью изменений...", "status.review_summary": "Ревью: %{count} блоков | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Режим ревью изменений остановлен.", @@ -582,23 +699,15 @@ "status.no_changes": "Нет изменений в этом файле", "status.failed_old_new_file": "Не удалось загрузить старую версию файла (файл может быть новым)", "prompt.comment": "Комментарий к %{line}: ", - "panel.no_changes": "Нет изменений для просмотра.", - "section.staged": "Подготовленные изменения", - "section.unstaged": "Изменённые (неподготовленные)", - "section.untracked": "Неотслеживаемые файлы", - "debug.loaded": "Плагин ревью изменений загружен с поддержкой комментариев", - "cmd.edit_note": "Ревью: Редактировать заметку", - "cmd.edit_note_desc": "Редактировать заметку сессии", - "prompt.discard_hunk": "Отбросить этот блок в \"%{file}\"? Это действие нельзя отменить.", "prompt.edit_comment": "Редактировать комментарий на %{line}: ", "prompt.overall_comment": "Заметка: ", - "status.hunk_discarded": "Блок отброшен", - "status.hunk_staged": "Блок подготовлен", - "status.hunk_unstaged": "Блок убран из подготовки", - "status.overall_comment_added": "Заметка добавлена", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Отбросить этот блок в \"%{file}\"? Это действие нельзя отменить.", + "panel.no_changes": "Нет изменений для просмотра.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Подготовленные изменения", + "section.unstaged": "Изменённые (неподготовленные)", + "section.untracked": "Неотслеживаемые файлы", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -607,7 +716,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Плагин ревью изменений загружен с поддержкой комментариев", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "th": { "cmd.review_diff": "ตรวจสอบความแตกต่าง", @@ -616,10 +726,23 @@ "cmd.stop_review_diff_desc": "หยุดเซสชันการตรวจสอบ", "cmd.refresh_review_diff": "รีเฟรชความแตกต่าง", "cmd.refresh_review_diff_desc": "รีเฟรชรายการการเปลี่ยนแปลง", + "cmd.review_branch": "ตรวจสอบสาขา PR", + "cmd.review_branch_desc": "ตรวจสอบคอมมิตทั้งหมดในสาขาปัจจุบันเทียบกับ ref ฐาน", + "cmd.stop_review_branch": "หยุดการตรวจสอบสาขา", + "cmd.stop_review_branch_desc": "ปิดแผงตรวจสอบสาขา PR", + "cmd.refresh_review_branch": "รีเฟรชสาขาสำหรับการตรวจสอบ", + "cmd.refresh_review_branch_desc": "โหลดรายการคอมมิตใหม่สำหรับ ref ฐานปัจจุบัน", + "prompt.branch_base": "ref ฐานสำหรับเปรียบเทียบ (ค่าเริ่มต้น: main):", + "status.review_branch_ready": "กำลังตรวจสอบคอมมิต %{count} รายการใน %{base}..HEAD", + "status.review_branch_empty": "ไม่มีคอมมิตใน %{base}..HEAD — ไม่มีอะไรต้องตรวจสอบ", + "panel.review_branch_header": "คอมมิต (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: นำทาง | Enter: รายละเอียด | r: รีเฟรช | q: ปิด", "cmd.side_by_side_diff": "เปรียบเทียบแบบเคียงข้าง", "cmd.side_by_side_diff_desc": "แสดงความแตกต่างของไฟล์ปัจจุบันแบบเคียงข้าง", "cmd.add_comment": "ตรวจสอบ: เพิ่มความคิดเห็น", "cmd.add_comment_desc": "เพิ่มความคิดเห็นการตรวจสอบไปยังบล็อกปัจจุบัน", + "cmd.edit_note": "ตรวจสอบ: แก้ไขบันทึก", + "cmd.edit_note_desc": "แก้ไขบันทึกของเซสชัน", "cmd.export_markdown": "ตรวจสอบ: ส่งออกเป็น Markdown", "cmd.export_markdown_desc": "ส่งออกการตรวจสอบไปยัง .review/session.md", "cmd.export_json": "ตรวจสอบ: ส่งออกเป็น JSON", @@ -632,9 +755,14 @@ "status.failed_new_version": "ไม่สามารถโหลดเวอร์ชันใหม่ของไฟล์", "status.diff_summary": "เปรียบเทียบแบบเคียงข้าง: +%{added} -%{removed} ~%{modified} | 'q' เพื่อกลับ", "status.no_hunk_selected": "ไม่ได้เลือกบล็อกสำหรับความคิดเห็น", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "เพิ่มความคิดเห็นที่ %{line} แล้ว", "status.comment_cancelled": "ยกเลิกความคิดเห็นแล้ว", + "status.overall_comment_added": "เพิ่มบันทึกแล้ว", "status.exported": "ส่งออกการตรวจสอบไปยัง %{path} แล้ว", + "status.hunk_staged": "สเตจบล็อกแล้ว", + "status.hunk_unstaged": "ยกเลิกสเตจบล็อกแล้ว", + "status.hunk_discarded": "ทิ้งบล็อกแล้ว", "status.generating": "กำลังสร้างสตรีมความแตกต่างการตรวจสอบ...", "status.review_summary": "ตรวจสอบ: %{count} บล็อก | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "หยุดโหมดตรวจสอบความแตกต่างแล้ว", @@ -643,23 +771,15 @@ "status.no_changes": "ไม่มีการเปลี่ยนแปลงในไฟล์นี้", "status.failed_old_new_file": "ไม่สามารถโหลดเวอร์ชันเก่าของไฟล์ (ไฟล์อาจเป็นไฟล์ใหม่)", "prompt.comment": "ความคิดเห็นที่ %{line}: ", - "panel.no_changes": "ไม่มีการเปลี่ยนแปลงให้ตรวจสอบ", - "section.staged": "การเปลี่ยนแปลงที่จัดเตรียมแล้ว", - "section.unstaged": "แก้ไขแล้ว (ยังไม่จัดเตรียม)", - "section.untracked": "ไฟล์ที่ไม่ได้ติดตาม", - "debug.loaded": "โหลดปลั๊กอินตรวจสอบความแตกต่างพร้อมรองรับความคิดเห็นแล้ว", - "cmd.edit_note": "ตรวจสอบ: แก้ไขบันทึก", - "cmd.edit_note_desc": "แก้ไขบันทึกของเซสชัน", - "prompt.discard_hunk": "ทิ้งบล็อกนี้ใน \"%{file}\" หรือไม่? การกระทำนี้ไม่สามารถย้อนกลับได้", "prompt.edit_comment": "แก้ไขความคิดเห็นที่ %{line}: ", "prompt.overall_comment": "บันทึก: ", - "status.hunk_discarded": "ทิ้งบล็อกแล้ว", - "status.hunk_staged": "สเตจบล็อกแล้ว", - "status.hunk_unstaged": "ยกเลิกสเตจบล็อกแล้ว", - "status.overall_comment_added": "เพิ่มบันทึกแล้ว", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "ทิ้งบล็อกนี้ใน \"%{file}\" หรือไม่? การกระทำนี้ไม่สามารถย้อนกลับได้", + "panel.no_changes": "ไม่มีการเปลี่ยนแปลงให้ตรวจสอบ", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "การเปลี่ยนแปลงที่จัดเตรียมแล้ว", + "section.unstaged": "แก้ไขแล้ว (ยังไม่จัดเตรียม)", + "section.untracked": "ไฟล์ที่ไม่ได้ติดตาม", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -668,7 +788,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "โหลดปลั๊กอินตรวจสอบความแตกต่างพร้อมรองรับความคิดเห็นแล้ว", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "uk": { "cmd.review_diff": "Перегляд змін", @@ -677,10 +798,23 @@ "cmd.stop_review_diff_desc": "Зупинити сеанс рев'ю", "cmd.refresh_review_diff": "Оновити зміни", "cmd.refresh_review_diff_desc": "Оновити список змін", + "cmd.review_branch": "Рев'ю гілки PR", + "cmd.review_branch_desc": "Переглянути всі коміти поточної гілки відносно базового ref", + "cmd.stop_review_branch": "Зупинити рев'ю гілки", + "cmd.stop_review_branch_desc": "Закрити панель рев'ю гілки PR", + "cmd.refresh_review_branch": "Оновити гілку для рев'ю", + "cmd.refresh_review_branch_desc": "Перезавантажити список комітів для поточного базового ref", + "prompt.branch_base": "Базовий ref для порівняння (за замовчуванням: main):", + "status.review_branch_ready": "Рев'ю %{count} комітів у %{base}..HEAD", + "status.review_branch_empty": "Немає комітів у %{base}..HEAD — нічого переглядати.", + "panel.review_branch_header": "Коміти (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: навігація | Enter: деталі | r: оновити | q: закрити", "cmd.side_by_side_diff": "Порівняння поруч", "cmd.side_by_side_diff_desc": "Показати зміни поточного файлу поруч", "cmd.add_comment": "Рев'ю: Додати коментар", "cmd.add_comment_desc": "Додати коментар до поточного блоку", + "cmd.edit_note": "Рев'ю: Редагувати нотатку", + "cmd.edit_note_desc": "Редагувати нотатку сесії", "cmd.export_markdown": "Рев'ю: Експорт у Markdown", "cmd.export_markdown_desc": "Експортувати рев'ю до .review/session.md", "cmd.export_json": "Рев'ю: Експорт у JSON", @@ -693,9 +827,14 @@ "status.failed_new_version": "Не вдалося завантажити нову версію файлу", "status.diff_summary": "Порівняння поруч: +%{added} -%{removed} ~%{modified} | 'q' для повернення", "status.no_hunk_selected": "Блок для коментаря не вибрано", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Коментар додано до %{line}", "status.comment_cancelled": "Коментар скасовано", + "status.overall_comment_added": "Нотатку додано", "status.exported": "Рев'ю експортовано до %{path}", + "status.hunk_staged": "Блок підготовлено", + "status.hunk_unstaged": "Блок прибрано з підготовки", + "status.hunk_discarded": "Блок відкинуто", "status.generating": "Генерація потоку змін рев'ю...", "status.review_summary": "Рев'ю: %{count} блоків | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Режим рев'ю змін зупинено.", @@ -704,23 +843,15 @@ "status.no_changes": "Немає змін у цьому файлі", "status.failed_old_new_file": "Не вдалося завантажити стару версію файлу (файл може бути новим)", "prompt.comment": "Коментар до %{line}: ", - "panel.no_changes": "Немає змін для перегляду.", - "section.staged": "Підготовлені зміни", - "section.unstaged": "Змінені (непідготовлені)", - "section.untracked": "Невідстежувані файли", - "debug.loaded": "Плагін рев'ю змін завантажено з підтримкою коментарів", - "cmd.edit_note": "Рев'ю: Редагувати нотатку", - "cmd.edit_note_desc": "Редагувати нотатку сесії", - "prompt.discard_hunk": "Відкинути цей блок у \"%{file}\"? Цю дію не можна скасувати.", "prompt.edit_comment": "Редагувати коментар на %{line}: ", "prompt.overall_comment": "Нотатка: ", - "status.hunk_discarded": "Блок відкинуто", - "status.hunk_staged": "Блок підготовлено", - "status.hunk_unstaged": "Блок прибрано з підготовки", - "status.overall_comment_added": "Нотатку додано", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Відкинути цей блок у \"%{file}\"? Цю дію не можна скасувати.", + "panel.no_changes": "Немає змін для перегляду.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Підготовлені зміни", + "section.unstaged": "Змінені (непідготовлені)", + "section.untracked": "Невідстежувані файли", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -729,7 +860,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Плагін рев'ю змін завантажено з підтримкою коментарів", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "vi": { "cmd.review_diff": "Xem xét khác biệt", @@ -738,10 +870,23 @@ "cmd.stop_review_diff_desc": "Dừng phiên xem xét", "cmd.refresh_review_diff": "Làm mới khác biệt", "cmd.refresh_review_diff_desc": "Làm mới danh sách thay đổi", + "cmd.review_branch": "Xem xét nhánh PR", + "cmd.review_branch_desc": "Xem xét tất cả commit trên nhánh hiện tại so với ref cơ sở", + "cmd.stop_review_branch": "Dừng xem xét nhánh", + "cmd.stop_review_branch_desc": "Đóng bảng xem xét nhánh PR", + "cmd.refresh_review_branch": "Làm mới nhánh xem xét", + "cmd.refresh_review_branch_desc": "Tải lại danh sách commit cho ref cơ sở hiện tại", + "prompt.branch_base": "Ref cơ sở để so sánh (mặc định: main):", + "status.review_branch_ready": "Đang xem xét %{count} commit trong %{base}..HEAD", + "status.review_branch_empty": "Không có commit nào trong %{base}..HEAD — không có gì để xem xét.", + "panel.review_branch_header": "Commit (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: điều hướng | Enter: chi tiết | r: làm mới | q: đóng", "cmd.side_by_side_diff": "So sánh song song", "cmd.side_by_side_diff_desc": "Hiển thị khác biệt song song cho tệp hiện tại", "cmd.add_comment": "Xem xét: Thêm nhận xét", "cmd.add_comment_desc": "Thêm nhận xét xem xét cho khối hiện tại", + "cmd.edit_note": "Xem xét: Sửa ghi chú", + "cmd.edit_note_desc": "Sửa ghi chú phiên", "cmd.export_markdown": "Xem xét: Xuất ra Markdown", "cmd.export_markdown_desc": "Xuất xem xét ra .review/session.md", "cmd.export_json": "Xem xét: Xuất ra JSON", @@ -754,9 +899,14 @@ "status.failed_new_version": "Không thể tải phiên bản mới của tệp", "status.diff_summary": "So sánh song song: +%{added} -%{removed} ~%{modified} | 'q' để quay lại", "status.no_hunk_selected": "Chưa chọn khối để nhận xét", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Đã thêm nhận xét vào %{line}", "status.comment_cancelled": "Đã hủy nhận xét", + "status.overall_comment_added": "Đã thêm ghi chú", "status.exported": "Đã xuất xem xét ra %{path}", + "status.hunk_staged": "Đã đưa khối vào vùng chuẩn bị", + "status.hunk_unstaged": "Đã bỏ khối khỏi vùng chuẩn bị", + "status.hunk_discarded": "Đã bỏ khối", "status.generating": "Đang tạo luồng khác biệt xem xét...", "status.review_summary": "Xem xét: %{count} khối | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Đã dừng chế độ xem xét khác biệt.", @@ -765,23 +915,15 @@ "status.no_changes": "Không có thay đổi trong tệp này", "status.failed_old_new_file": "Không thể tải phiên bản cũ của tệp (tệp có thể là mới)", "prompt.comment": "Nhận xét trên %{line}: ", - "panel.no_changes": "Không có thay đổi để xem xét.", - "section.staged": "Thay đổi đã chuẩn bị", - "section.unstaged": "Đã sửa đổi (chưa chuẩn bị)", - "section.untracked": "Tệp không được theo dõi", - "debug.loaded": "Plugin xem xét khác biệt đã tải với hỗ trợ nhận xét", - "cmd.edit_note": "Xem xét: Sửa ghi chú", - "cmd.edit_note_desc": "Sửa ghi chú phiên", - "prompt.discard_hunk": "Bỏ khối này trong \"%{file}\"? Hành động này không thể hoàn tác.", "prompt.edit_comment": "Sửa nhận xét tại %{line}: ", "prompt.overall_comment": "Ghi chú: ", - "status.hunk_discarded": "Đã bỏ khối", - "status.hunk_staged": "Đã đưa khối vào vùng chuẩn bị", - "status.hunk_unstaged": "Đã bỏ khối khỏi vùng chuẩn bị", - "status.overall_comment_added": "Đã thêm ghi chú", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Bỏ khối này trong \"%{file}\"? Hành động này không thể hoàn tác.", + "panel.no_changes": "Không có thay đổi để xem xét.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Thay đổi đã chuẩn bị", + "section.unstaged": "Đã sửa đổi (chưa chuẩn bị)", + "section.untracked": "Tệp không được theo dõi", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -790,7 +932,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin xem xét khác biệt đã tải với hỗ trợ nhận xét", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "zh-CN": { "cmd.review_diff": "审查差异", @@ -799,10 +942,23 @@ "cmd.stop_review_diff_desc": "停止审查会话", "cmd.refresh_review_diff": "刷新差异", "cmd.refresh_review_diff_desc": "刷新更改列表", + "cmd.review_branch": "审查 PR 分支", + "cmd.review_branch_desc": "将当前分支的所有提交与基础 ref 进行比较审查", + "cmd.stop_review_branch": "停止分支审查", + "cmd.stop_review_branch_desc": "关闭 PR 分支审查面板", + "cmd.refresh_review_branch": "刷新审查分支", + "cmd.refresh_review_branch_desc": "重新获取当前基础 ref 的提交列表", + "prompt.branch_base": "用于比较的基础 ref(默认:main):", + "status.review_branch_ready": "正在审查 %{base}..HEAD 中的 %{count} 个提交", + "status.review_branch_empty": "%{base}..HEAD 中没有提交 — 无需审查。", + "panel.review_branch_header": "提交 (%{base}..HEAD)", + "panel.review_branch_footer": "j/k:导航 | Enter:详情 | r:刷新 | q:关闭", "cmd.side_by_side_diff": "并排差异", "cmd.side_by_side_diff_desc": "并排显示当前文件的差异", "cmd.add_comment": "审查: 添加评论", "cmd.add_comment_desc": "为当前代码块添加审查评论", + "cmd.edit_note": "审查: 编辑备注", + "cmd.edit_note_desc": "编辑会话备注", "cmd.export_markdown": "审查: 导出为Markdown", "cmd.export_markdown_desc": "将审查导出到.review/session.md", "cmd.export_json": "审查: 导出为JSON", @@ -815,9 +971,14 @@ "status.failed_new_version": "加载新文件版本失败", "status.diff_summary": "并排差异: +%{added} -%{removed} ~%{modified} | 按'q'返回", "status.no_hunk_selected": "未选择要评论的代码块", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "评论已添加到%{line}", "status.comment_cancelled": "评论已取消", + "status.overall_comment_added": "备注已添加", "status.exported": "审查已导出到%{path}", + "status.hunk_staged": "代码块已暂存", + "status.hunk_unstaged": "代码块已取消暂存", + "status.hunk_discarded": "代码块已丢弃", "status.generating": "正在生成审查差异流...", "status.review_summary": "审查差异: %{count}个代码块 | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "审查差异模式已停止。", @@ -826,23 +987,15 @@ "status.no_changes": "此文件没有更改", "status.failed_old_new_file": "加载旧文件版本失败(文件可能是新建的)", "prompt.comment": "在%{line}上评论: ", - "panel.no_changes": "没有需要审查的更改。", - "section.staged": "已暂存的更改", - "section.unstaged": "已修改(未暂存)", - "section.untracked": "未跟踪的文件", - "debug.loaded": "审查差异插件已加载,支持评论功能", - "cmd.edit_note": "审查: 编辑备注", - "cmd.edit_note_desc": "编辑会话备注", - "prompt.discard_hunk": "丢弃 \"%{file}\" 中的此代码块?此操作无法撤销。", "prompt.edit_comment": "编辑 %{line} 的评论: ", "prompt.overall_comment": "备注: ", - "status.hunk_discarded": "代码块已丢弃", - "status.hunk_staged": "代码块已暂存", - "status.hunk_unstaged": "代码块已取消暂存", - "status.overall_comment_added": "备注已添加", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "丢弃 \"%{file}\" 中的此代码块?此操作无法撤销。", + "panel.no_changes": "没有需要审查的更改。", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "已暂存的更改", + "section.unstaged": "已修改(未暂存)", + "section.untracked": "未跟踪的文件", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -851,6 +1004,7 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "审查差异插件已加载,支持评论功能", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" } } diff --git a/crates/fresh-editor/plugins/audit_mode.ts b/crates/fresh-editor/plugins/audit_mode.ts index 81d835116..7683753f6 100644 --- a/crates/fresh-editor/plugins/audit_mode.ts +++ b/crates/fresh-editor/plugins/audit_mode.ts @@ -9,6 +9,14 @@ const editor = getEditor(); import { createVirtualBufferFactory } from "./lib/virtual-buffer-factory.ts"; +import { + type GitCommit, + buildCommitDetailEntries, + buildCommitLogEntries, + buildDetailPlaceholderEntries, + fetchCommitShow, + fetchGitLog, +} from "./lib/git_history.ts"; const VirtualBufferFactory = createVirtualBufferFactory(editor); @@ -3874,8 +3882,314 @@ async function side_by_side_diff_current_file() { } registerHandler("side_by_side_diff_current_file", side_by_side_diff_current_file); +// ============================================================================= +// Review PR Branch +// +// A companion view to `start_review_diff` for reviewing the full set of +// commits on a PR branch (rather than just the working-tree changes). It +// opens a buffer group with the commit history on the left (rendered by +// the shared `lib/git_history.ts` helpers the git_log plugin uses) and a +// live-updating `git show` of the selected commit on the right. This reuses +// the same rendering pipeline so both plugins stay visually consistent and +// respect theme keys in one place. +// ============================================================================= + +interface ReviewBranchState { + isOpen: boolean; + groupId: number | null; + logBufferId: number | null; + detailBufferId: number | null; + commits: GitCommit[]; + selectedIndex: number; + baseRef: string; + detailCache: { hash: string; output: string } | null; + pendingDetailId: number; + /** Byte offset of each row in the log panel; final entry = buffer length. */ + logRowByteOffsets: number[]; +} + +const branchState: ReviewBranchState = { + isOpen: false, + groupId: null, + logBufferId: null, + detailBufferId: null, + commits: [], + selectedIndex: 0, + baseRef: "main", + detailCache: null, + pendingDetailId: 0, + logRowByteOffsets: [], +}; + +// UTF-8 byte length helper, local copy so audit_mode doesn't pull in the one +// from git_history (keeps the import list tiny). +function branchUtf8Len(s: string): number { + let b = 0; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c <= 0x7f) b += 1; + else if (c <= 0x7ff) b += 2; + else if (c >= 0xd800 && c <= 0xdfff) { b += 4; i++; } + else b += 3; + } + return b; +} + +function branchRowFromByte(bytePos: number): number { + const offs = branchState.logRowByteOffsets; + if (offs.length === 0) return 0; + let lo = 0; + let hi = offs.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (offs[mid] <= bytePos) lo = mid; + else hi = mid - 1; + } + return lo; +} + +function branchIndexFromCursor(bytePos: number): number { + const row = branchRowFromByte(bytePos); + const idx = row - 1; // row 0 is the header + if (idx < 0) return 0; + if (idx >= branchState.commits.length) return branchState.commits.length - 1; + return idx; +} + +function branchRenderLog(): void { + if (branchState.groupId === null) return; + const rawHeader = editor.t("panel.review_branch_header", { base: branchState.baseRef }); + const header = (rawHeader && !rawHeader.startsWith("panel.")) ? rawHeader : `Commits (${branchState.baseRef}..HEAD)`; + const rawFooter = editor.t("panel.review_branch_footer"); + const footer = (rawFooter && !rawFooter.startsWith("panel.")) ? rawFooter : "j/k: navigate · Enter: focus detail · r: refresh · q: close"; + const entries = buildCommitLogEntries(branchState.commits, { + selectedIndex: branchState.selectedIndex, + header, + footer, + propertyType: "branch-commit", + }); + const offsets: number[] = []; + let running = 0; + for (const e of entries) { + offsets.push(running); + running += branchUtf8Len(e.text); + } + offsets.push(running); + branchState.logRowByteOffsets = offsets; + editor.setPanelContent(branchState.groupId, "log", entries); +} + +function branchByteOffsetOfFirstCommit(): number { + return branchState.logRowByteOffsets.length > 1 ? branchState.logRowByteOffsets[1] : 0; +} + +async function branchRefreshDetail(): Promise { + if (branchState.groupId === null) return; + if (branchState.commits.length === 0) { + const msg = editor.t("status.review_branch_empty") || "No commits in the selected range."; + editor.setPanelContent( + branchState.groupId, + "detail", + buildDetailPlaceholderEntries(msg), + ); + return; + } + const idx = Math.max(0, Math.min(branchState.selectedIndex, branchState.commits.length - 1)); + const commit = branchState.commits[idx]; + if (!commit) return; + + if (branchState.detailCache && branchState.detailCache.hash === commit.hash) { + const entries = buildCommitDetailEntries(commit, branchState.detailCache.output, {}); + editor.setPanelContent(branchState.groupId, "detail", entries); + return; + } + const myId = ++branchState.pendingDetailId; + editor.setPanelContent( + branchState.groupId, + "detail", + buildDetailPlaceholderEntries( + editor.t("status.loading_commit", { hash: commit.shortHash }) || `Loading ${commit.shortHash}…`, + ), + ); + const output = await fetchCommitShow(editor, commit.hash); + if (myId !== branchState.pendingDetailId) return; + if (branchState.groupId === null) return; + branchState.detailCache = { hash: commit.hash, output }; + editor.setPanelContent( + branchState.groupId, + "detail", + buildCommitDetailEntries(commit, output, {}), + ); +} + +async function start_review_branch(): Promise { + if (branchState.isOpen) { + editor.setStatus(editor.t("status.already_open") || "Review branch already open"); + return; + } + // Prompt for the base ref so the user can review any PR, not just + // one branched off main. + const input = await editor.prompt( + editor.t("prompt.branch_base") || "Base ref (default: main):", + branchState.baseRef, + ); + if (input === null) { + editor.setStatus(editor.t("status.cancelled") || "Cancelled"); + return; + } + const base = input.trim() || "main"; + branchState.baseRef = base; + + editor.setStatus(editor.t("status.loading") || "Loading commits…"); + branchState.commits = await fetchGitLog(editor, { range: `${base}..HEAD`, maxCommits: 500 }); + if (branchState.commits.length === 0) { + editor.setStatus( + editor.t("status.review_branch_empty", { base }) || + `No commits in ${base}..HEAD — nothing to review.`, + ); + return; + } + + const layout = JSON.stringify({ + type: "split", + direction: "h", + ratio: 0.4, + first: { type: "scrollable", id: "log" }, + second: { type: "scrollable", id: "detail" }, + }); + // `createBufferGroup` is a runtime-only binding (not in the generated + // EditorAPI type); cast to `any` so the type-checker doesn't complain. + const group = await (editor as any).createBufferGroup( + `*Review Branch ${base}..HEAD*`, + "review-branch", + layout, + ); + branchState.groupId = group.groupId as number; + branchState.logBufferId = (group.panels["log"] as number | undefined) ?? null; + branchState.detailBufferId = (group.panels["detail"] as number | undefined) ?? null; + branchState.selectedIndex = 0; + branchState.detailCache = null; + branchState.isOpen = true; + + if (branchState.logBufferId !== null) { + editor.setBufferShowCursors(branchState.logBufferId, true); + } + if (branchState.detailBufferId !== null) { + editor.setBufferShowCursors(branchState.detailBufferId, true); + } + + branchRenderLog(); + if (branchState.logBufferId !== null && branchState.commits.length > 0) { + editor.setBufferCursor(branchState.logBufferId, branchByteOffsetOfFirstCommit()); + } + await branchRefreshDetail(); + + if (branchState.groupId !== null) { + editor.focusBufferGroupPanel(branchState.groupId, "log"); + } + editor.on("cursor_moved", "on_review_branch_cursor_moved"); + + editor.setStatus( + editor.t("status.review_branch_ready", { + count: String(branchState.commits.length), + base, + }) || `Reviewing ${branchState.commits.length} commits in ${base}..HEAD`, + ); +} +registerHandler("start_review_branch", start_review_branch); + +function stop_review_branch(): void { + if (!branchState.isOpen) return; + if (branchState.groupId !== null) editor.closeBufferGroup(branchState.groupId); + editor.off("cursor_moved", "on_review_branch_cursor_moved"); + branchState.isOpen = false; + branchState.groupId = null; + branchState.logBufferId = null; + branchState.detailBufferId = null; + branchState.commits = []; + branchState.selectedIndex = 0; + branchState.detailCache = null; + editor.setStatus(editor.t("status.closed") || "Review branch closed"); +} +registerHandler("stop_review_branch", stop_review_branch); + +async function review_branch_refresh(): Promise { + if (!branchState.isOpen) return; + const base = branchState.baseRef; + branchState.commits = await fetchGitLog(editor, { range: `${base}..HEAD`, maxCommits: 500 }); + branchState.detailCache = null; + if (branchState.selectedIndex >= branchState.commits.length) { + branchState.selectedIndex = Math.max(0, branchState.commits.length - 1); + } + branchRenderLog(); + await branchRefreshDetail(); +} +registerHandler("review_branch_refresh", review_branch_refresh); + +/** Enter: focus the detail panel (so the user can scroll/click within it). */ +function review_branch_enter(): void { + if (branchState.groupId === null) return; + editor.focusBufferGroupPanel(branchState.groupId, "detail"); +} +registerHandler("review_branch_enter", review_branch_enter); + +/** q/Escape: focus-back from detail, or close when already on log. */ +function review_branch_close_or_back(): void { + if (branchState.groupId === null) return; + const active = editor.getActiveBufferId(); + if (branchState.detailBufferId !== null && active === branchState.detailBufferId) { + editor.focusBufferGroupPanel(branchState.groupId, "log"); + return; + } + stop_review_branch(); +} +registerHandler("review_branch_close_or_back", review_branch_close_or_back); + +function on_review_branch_cursor_moved(data: { + buffer_id: number; + cursor_id: number; + old_position: number; + new_position: number; +}): void { + if (!branchState.isOpen) return; + if (data.buffer_id !== branchState.logBufferId) return; + const idx = branchIndexFromCursor(data.new_position); + if (idx === branchState.selectedIndex) return; + branchState.selectedIndex = idx; + branchRenderLog(); + branchRefreshDetail(); +} +registerHandler("on_review_branch_cursor_moved", on_review_branch_cursor_moved); + +editor.defineMode( + "review-branch", + [ + // Mode bindings replace globals, so we re-bind the editor's built-in + // motion actions here explicitly — without this, j/k and Up/Down + // do nothing in the commit list. + ["Up", "move_up"], + ["Down", "move_down"], + ["k", "move_up"], + ["j", "move_down"], + ["PageUp", "page_up"], + ["PageDown", "page_down"], + ["Home", "move_line_start"], + ["End", "move_line_end"], + // Enter: focus the right-hand detail panel. + ["Return", "review_branch_enter"], + ["Tab", "review_branch_enter"], + ["r", "review_branch_refresh"], + ["q", "review_branch_close_or_back"], + ["Escape", "review_branch_close_or_back"], + ], + true, +); + // Register Modes and Commands editor.registerCommand("%cmd.review_diff", "%cmd.review_diff_desc", "start_review_diff", null); +editor.registerCommand("%cmd.review_branch", "%cmd.review_branch_desc", "start_review_branch", null); +editor.registerCommand("%cmd.stop_review_branch", "%cmd.stop_review_branch_desc", "stop_review_branch", "review-branch"); +editor.registerCommand("%cmd.refresh_review_branch", "%cmd.refresh_review_branch_desc", "review_branch_refresh", "review-branch"); editor.registerCommand("%cmd.stop_review_diff", "%cmd.stop_review_diff_desc", "stop_review_diff", "review-mode"); editor.registerCommand("%cmd.refresh_review_diff", "%cmd.refresh_review_diff_desc", "review_refresh", "review-mode"); editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc", "side_by_side_diff_current_file", null); diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index ccc018247..77f61fa54 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -1,1163 +1,760 @@ /// -const editor = getEditor(); +import { + type GitCommit, + buildCommitDetailEntries, + buildCommitLogEntries, + buildDetailPlaceholderEntries, + fetchCommitShow, + fetchGitLog, +} from "./lib/git_history.ts"; + +const editor = getEditor(); /** - * Git Log Plugin - Magit-style Git Log Interface + * Git Log Plugin — Magit-style git history interface built on top of the + * modern plugin API primitives: * - * Provides an interactive git log view with: - * - Syntax highlighting for hash, author, date, subject - * - Cursor navigation between commits - * - Enter to open commit details in a virtual buffer + * * `createBufferGroup` for a side-by-side "log | detail" layout that + * appears as a single tab with its own inner scroll state. + * * `setPanelContent` with `TextPropertyEntry[]` + `inlineOverlays` for + * aligned columns and per-theme colouring (every colour is a theme key, + * so the panel follows theme changes). + * * `cursor_moved` subscription to live-update the right-hand detail panel + * as the user scrolls through the commit list. * - * Architecture designed for future magit-style features. + * The rendering helpers live in `lib/git_history.ts` so the same commit-list + * view can be reused by `audit_mode`'s PR-branch review mode. */ // ============================================================================= -// Types and Interfaces +// State // ============================================================================= -interface GitCommit { - hash: string; - shortHash: string; - author: string; - authorEmail: string; - date: string; - relativeDate: string; - subject: string; - body: string; - refs: string; // Branch/tag refs - graph: string; // Graph characters -} - -interface GitLogOptions { - showGraph: boolean; - showRefs: boolean; - maxCommits: number; -} - interface GitLogState { isOpen: boolean; - bufferId: number | null; - splitId: number | null; // The split where git log is displayed - sourceBufferId: number | null; // The buffer that was open before git log (to restore on close) + groupId: number | null; + logBufferId: number | null; + detailBufferId: number | null; + toolbarBufferId: number | null; + /** Click-regions for the toolbar's buttons, populated by `renderToolbar`. */ + toolbarButtons: ToolbarButton[]; commits: GitCommit[]; - options: GitLogOptions; - cachedContent: string; // Store content for highlighting (getBufferText doesn't work for virtual buffers) -} - -interface GitCommitDetailState { - isOpen: boolean; - bufferId: number | null; - splitId: number | null; - commit: GitCommit | null; - cachedContent: string; // Store content for highlighting + selectedIndex: number; + /** Cached `git show` output for the currently-displayed detail commit. */ + detailCache: { hash: string; output: string } | null; + /** + * In-flight detail request id. Used to ignore stale responses when the + * user scrolls through the log faster than `git show` can return. + */ + pendingDetailId: number; + /** + * Debounce token for `cursor_moved`. Rapid cursor motion (PageDown, held + * j/k) would otherwise trigger a full log re-render + `git show` per + * intermediate row; we bump this id on every event and only do the work + * after a short delay if no newer event has arrived. + */ + pendingCursorMoveId: number; + /** + * Byte offset at the start of each row in the rendered log panel, plus + * the total buffer length at the end. Populated by `renderLog` so the + * cursor_moved handler can map byte positions to commit indices without + * relying on `getCursorLine` (which is not implemented for virtual + * buffers). + */ + logRowByteOffsets: number[]; } -interface GitFileViewState { - isOpen: boolean; - bufferId: number | null; - splitId: number | null; - filePath: string | null; - commitHash: string | null; -} - -// ============================================================================= -// State Management -// ============================================================================= - -const gitLogState: GitLogState = { +const state: GitLogState = { isOpen: false, - bufferId: null, - splitId: null, - sourceBufferId: null, + groupId: null, + logBufferId: null, + detailBufferId: null, + toolbarBufferId: null, + toolbarButtons: [], commits: [], - options: { - showGraph: false, // Disabled by default - graph interferes with format parsing - showRefs: true, - maxCommits: 100, - }, - cachedContent: "", -}; - -const commitDetailState: GitCommitDetailState = { - isOpen: false, - bufferId: null, - splitId: null, - commit: null, - cachedContent: "", -}; - -const fileViewState: GitFileViewState = { - isOpen: false, - bufferId: null, - splitId: null, - filePath: null, - commitHash: null, + selectedIndex: 0, + detailCache: null, + pendingDetailId: 0, + pendingCursorMoveId: 0, + logRowByteOffsets: [], }; -// ============================================================================= -// Color Definitions (for syntax highlighting) -// ============================================================================= +/** + * Delay before reacting to `cursor_moved`. Long enough to collapse a burst + * of events from held j/k or PageDown into a single render, short enough + * that the detail panel still feels live. + */ +const CURSOR_DEBOUNCE_MS = 60; + +// UTF-8 byte length — the overlay API expects byte offsets; JS strings are +// UTF-16. Matches the helper used by `lib/git_history.ts`. +function utf8Len(s: string): number { + let b = 0; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c <= 0x7f) b += 1; + else if (c <= 0x7ff) b += 2; + else if (c >= 0xd800 && c <= 0xdfff) { + b += 4; + i++; + } else b += 3; + } + return b; +} -const colors = { - hash: [255, 180, 50] as [number, number, number], // Yellow/Orange - author: [100, 200, 255] as [number, number, number], // Cyan - date: [150, 255, 150] as [number, number, number], // Green - subject: [255, 255, 255] as [number, number, number], // White - header: [255, 200, 100] as [number, number, number], // Gold - separator: [100, 100, 100] as [number, number, number], // Gray - selected: [80, 80, 120] as [number, number, number], // Selection background - diffAdd: [100, 255, 100] as [number, number, number], // Green for additions - diffDel: [255, 100, 100] as [number, number, number], // Red for deletions - diffHunk: [150, 150, 255] as [number, number, number], // Blue for hunk headers - branch: [255, 150, 255] as [number, number, number], // Magenta for branches - tag: [255, 255, 100] as [number, number, number], // Yellow for tags - remote: [255, 130, 100] as [number, number, number], // Orange for remotes - graph: [150, 150, 150] as [number, number, number], // Gray for graph - // Syntax highlighting colors - syntaxKeyword: [200, 120, 220] as [number, number, number], // Purple for keywords - syntaxString: [180, 220, 140] as [number, number, number], // Light green for strings - syntaxComment: [120, 120, 120] as [number, number, number], // Gray for comments - syntaxNumber: [220, 180, 120] as [number, number, number], // Orange for numbers - syntaxFunction: [100, 180, 255] as [number, number, number], // Blue for functions - syntaxType: [80, 200, 180] as [number, number, number], // Teal for types -}; +/** + * Binary search `logRowByteOffsets` for the 0-indexed row whose byte + * offset is the largest one ≤ `bytePos`. Returns 0 on an empty table. + */ +function rowFromByte(bytePos: number): number { + const offs = state.logRowByteOffsets; + if (offs.length === 0) return 0; + let lo = 0; + let hi = offs.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (offs[mid] <= bytePos) lo = mid; + else hi = mid - 1; + } + return lo; +} // ============================================================================= -// Mode Definitions +// Modes +// +// A buffer group has a single mode shared by all of its panels, so the +// handlers below branch on which panel currently has focus to do the +// right thing (`Return` jumps into the detail panel when pressed in +// the log, and opens the file at the cursor when pressed in the detail). // ============================================================================= -// Define git-log mode with minimal keybindings -// Navigation uses normal cursor movement (arrows, j/k work naturally via parent mode) +// j/k as vi-style aliases for Up/Down, plus the plugin-specific action +// keys. Everything else (arrows, Page{Up,Down}, Home/End, Shift+motion for +// selection, Ctrl+C copy, …) is inherited from the Normal keymap because +// the mode is registered with `inheritNormalBindings: true`. editor.defineMode( "git-log", [ - ["Return", "git_log_show_commit"], - ["Tab", "git_log_show_commit"], - ["q", "git_log_close"], - ["Escape", "git_log_close"], + ["k", "move_up"], + ["j", "move_down"], + ["Return", "git_log_enter"], + ["Tab", "git_log_tab"], + ["q", "git_log_q"], ["r", "git_log_refresh"], ["y", "git_log_copy_hash"], ], - true // read-only + true, // read-only + false, // allow_text_input + true, // inherit Normal-context bindings for unbound keys ); -// Define git-commit-detail mode for viewing commit details -// Inherits from normal mode for natural cursor movement -editor.defineMode( - "git-commit-detail", - [ - ["Return", "git_commit_detail_open_file"], - ["q", "git_commit_detail_close"], - ["Escape", "git_commit_detail_close"], - ], - true // read-only -); +// ============================================================================= +// Panel layout +// ============================================================================= -// Define git-file-view mode for viewing files at a specific commit -editor.defineMode( - "git-file-view", - [ - ["q", "git_file_view_close"], - ["Escape", "git_file_view_close"], - ], - true // read-only -); +/** + * Group buffer layout — a one-row sticky toolbar on top, then a horizontal + * split below with the commit log on the left (60%) and detail on the + * right (40%). The toolbar mirrors the review-diff style: a fixed-height + * panel above the scrollable content that holds all the keybinding hints + * so they don't shift or scroll with the data. + */ +const GROUP_LAYOUT = JSON.stringify({ + type: "split", + direction: "v", + ratio: 0.05, // ignored when one side is `fixed` + first: { type: "fixed", id: "toolbar", height: 1 }, + second: { + type: "split", + direction: "h", + ratio: 0.6, + first: { type: "scrollable", id: "log" }, + second: { type: "scrollable", id: "detail" }, + }, +}); // ============================================================================= -// Git Command Execution +// Toolbar // ============================================================================= -async function fetchGitLog(): Promise { - // Use record separator to reliably split commits - // Format: hash, short hash, author, email, date, relative date, refs, subject, body - const format = "%H%x00%h%x00%an%x00%ae%x00%ai%x00%ar%x00%d%x00%s%x00%b%x1e"; +interface ToolbarHint { + key: string; + label: string; + /** Click action — `null` for hints that are keyboard-only (j/k, PgUp). */ + onClick: (() => void | Promise) | null; +} + +interface ToolbarButton { + row: number; + startCol: number; + endCol: number; + onClick: (() => void | Promise) | null; +} - const args = [ - "log", - `--format=${format}`, - `-n${gitLogState.options.maxCommits}`, +function toolbarHints(): ToolbarHint[] { + return [ + { key: "Tab", label: "switch pane", onClick: git_log_tab }, + { key: "RET", label: "open file", onClick: git_log_enter }, + { key: "y", label: "copy hash", onClick: git_log_copy_hash }, + { key: "r", label: "refresh", onClick: git_log_refresh }, + { key: "q", label: "quit", onClick: git_log_q }, ]; +} - const cwd = editor.getCwd(); - const result = await editor.spawnProcess("git", args, cwd); +/** + * Build the single-row toolbar. Each hint renders as a discrete button with + * its own background so it reads as clickable; the column range of each + * button is captured in `state.toolbarButtons` so `on_git_log_toolbar_click` + * can map a mouse click back to the right handler. + */ +function buildToolbarEntries(width: number): TextPropertyEntry[] { + const W = Math.max(20, width); + const buttons: ToolbarButton[] = []; + let text = ""; + const overlays: InlineOverlay[] = []; + + for (const hint of toolbarHints()) { + const body = ` [${hint.key}] ${hint.label} `; + const bodyLen = body.length; + const gap = text.length > 0 ? 1 : 0; + if (text.length + gap + bodyLen > W) break; + + if (gap) text += " "; + + const startCol = text.length; + const startByte = utf8Len(text); + text += body; + const endByte = utf8Len(text); + const endCol = text.length; + + overlays.push({ + start: startByte, + end: endByte, + style: { bg: "ui.status_bar_bg" }, + }); + const keyDisplay = `[${hint.key}]`; + const keyStartByte = startByte + utf8Len(" "); + const keyEndByte = keyStartByte + utf8Len(keyDisplay); + overlays.push({ + start: keyStartByte, + end: keyEndByte, + style: { fg: "editor.fg", bold: true }, + }); + overlays.push({ + start: keyEndByte, + end: endByte, + style: { fg: "editor.line_number_fg" }, + }); - if (result.exit_code !== 0) { - editor.setStatus(editor.t("status.git_error", { error: result.stderr })); - return []; + buttons.push({ row: 0, startCol, endCol, onClick: hint.onClick }); } - const commits: GitCommit[] = []; - // Split by record separator (0x1e) - const records = result.stdout.split("\x1e"); - - for (const record of records) { - if (!record.trim()) continue; - - const parts = record.split("\x00"); - if (parts.length >= 8) { - commits.push({ - hash: parts[0].trim(), - shortHash: parts[1].trim(), - author: parts[2].trim(), - authorEmail: parts[3].trim(), - date: parts[4].trim(), - relativeDate: parts[5].trim(), - refs: parts[6].trim(), - subject: parts[7].trim(), - body: parts[8] ? parts[8].trim() : "", - graph: "", // Graph is handled separately if needed - }); - } - } + state.toolbarButtons = buttons; - return commits; + return [ + { + text: text + "\n", + properties: { type: "git-log-toolbar" }, + style: { bg: "editor.bg", extendToLineEnd: true }, + inlineOverlays: overlays, + }, + ]; } -async function fetchCommitDiff(hash: string): Promise { - const cwd = editor.getCwd(); - const result = await editor.spawnProcess("git", [ - "show", - "--stat", - "--patch", - hash, - ], cwd); +function renderToolbar(): void { + if (state.groupId === null) return; + const vp = editor.getViewport(); + const width = vp ? vp.width : 80; + editor.setPanelContent(state.groupId, "toolbar", buildToolbarEntries(width)); +} - if (result.exit_code !== 0) { - return editor.t("status.error_fetching_diff", { error: result.stderr }); +function on_git_log_toolbar_click(data: { + buffer_id: number | null; + buffer_row: number | null; + buffer_col: number | null; +}): void { + if (!state.isOpen) return; + if (data.buffer_id === null || data.buffer_id !== state.toolbarBufferId) return; + if (data.buffer_row === null || data.buffer_col === null) return; + const row = data.buffer_row; + const col = data.buffer_col; + const hit = state.toolbarButtons.find( + (b) => b.row === row && col >= b.startCol && col < b.endCol + ); + if (hit && hit.onClick) { + void hit.onClick(); } +} +registerHandler("on_git_log_toolbar_click", on_git_log_toolbar_click); - return result.stdout; +function on_git_log_resize(_data: { width: number; height: number }): void { + if (!state.isOpen) return; + renderToolbar(); } +registerHandler("on_git_log_resize", on_git_log_resize); // ============================================================================= -// Git Log View +// Rendering // ============================================================================= -function formatCommitRow(commit: GitCommit): string { - // Build a structured line for consistent parsing and highlighting - // Format: shortHash (author, relativeDate) subject [refs] - let line = commit.shortHash; - - // Add author in parentheses - line += " (" + commit.author + ", " + commit.relativeDate + ")"; - - // Add subject - line += " " + commit.subject; - - // Add refs at the end if present and enabled - if (gitLogState.options.showRefs && commit.refs) { - line += " " + commit.refs; - } - - return line + "\n"; +function detailFooter(hash: string): string { + return editor.t("status.commit_ready", { hash }); } -// Helper to extract content string from entries (for highlighting) -function entriesToContent(entries: TextPropertyEntry[]): string { - return entries.map(e => e.text).join(""); +function renderLog(): void { + if (state.groupId === null) return; + // No header row and no footer: the sticky toolbar above the group + // carries the shortcut hints, and the commit count goes to the status + // line when the group opens. + const entries = buildCommitLogEntries(state.commits, { + selectedIndex: state.selectedIndex, + header: null, + }); + // Rebuild the byte-offset table used by cursor_moved to map positions + // to commit indices. `offsets[i]` is the byte offset of commit i; the + // final entry is the total buffer length, so row lookups clamp + // correctly on the last row. + const offsets: number[] = []; + let running = 0; + for (const e of entries) { + offsets.push(running); + running += utf8Len(e.text); + } + offsets.push(running); + state.logRowByteOffsets = offsets; + editor.setPanelContent(state.groupId, "log", entries); } -function buildGitLogEntries(): TextPropertyEntry[] { - const entries: TextPropertyEntry[] = []; - - // Magit-style header - entries.push({ - text: editor.t("panel.commits_header") + "\n", - properties: { type: "section-header" }, - }); +function renderDetailPlaceholder(message: string): void { + if (state.groupId === null) return; + editor.setPanelContent( + state.groupId, + "detail", + buildDetailPlaceholderEntries(message) + ); +} - if (gitLogState.commits.length === 0) { - entries.push({ - text: editor.t("panel.no_commits") + "\n", - properties: { type: "empty" }, - }); - } else { - // Add each commit - for (let i = 0; i < gitLogState.commits.length; i++) { - const commit = gitLogState.commits[i]; - entries.push({ - text: formatCommitRow(commit), - properties: { - type: "commit", - index: i, - hash: commit.hash, - shortHash: commit.shortHash, - author: commit.author, - date: commit.relativeDate, - subject: commit.subject, - refs: commit.refs, - graph: commit.graph, - }, - }); - } +function renderDetailForCommit(commit: GitCommit, showOutput: string): void { + if (state.groupId === null) return; + const entries = buildCommitDetailEntries(commit, showOutput); + editor.setPanelContent(state.groupId, "detail", entries); + // Always scroll the detail panel back to the top when the selection changes. + if (state.detailBufferId !== null) { + editor.setBufferCursor(state.detailBufferId, 0); } - - // Footer with help - entries.push({ - text: "\n", - properties: { type: "blank" }, - }); - entries.push({ - text: editor.t("panel.log_footer", { count: String(gitLogState.commits.length) }) + "\n", - properties: { type: "footer" }, - }); - - return entries; } -function applyGitLogHighlighting(): void { - if (gitLogState.bufferId === null) return; - - const bufferId = gitLogState.bufferId; - - // Clear existing overlays - editor.clearNamespace(bufferId, "gitlog"); - - // Use cached content (getBufferText doesn't work for virtual buffers) - const content = gitLogState.cachedContent; - if (!content) return; - const lines = content.split("\n"); - - // Get cursor line to highlight current row (1-indexed from API) - const cursorLine = editor.getCursorLine(); - const headerLines = 1; // Just "Commits:" header - - let byteOffset = 0; - - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - const lineStart = byteOffset; - const lineEnd = byteOffset + line.length; - - // Highlight section header - if (line === editor.t("panel.commits_header")) { - editor.addOverlay(bufferId, "gitlog", lineStart, lineEnd, { - fg: colors.header, - underline: true, - bold: true, - }); - byteOffset += line.length + 1; - continue; - } - - const commitIndex = lineIdx - headerLines; - if (commitIndex < 0 || commitIndex >= gitLogState.commits.length) { - byteOffset += line.length + 1; - continue; - } - - const commit = gitLogState.commits[commitIndex]; - // cursorLine is 1-indexed, lineIdx is 0-indexed - const isCurrentLine = (lineIdx + 1) === cursorLine; - - // Highlight entire line if cursor is on it (using selected color with underline) - if (isCurrentLine) { - editor.addOverlay(bufferId, "gitlog", lineStart, lineEnd, { - fg: colors.selected, - underline: true, - bold: true, - }); - } - - // Parse the line format: "shortHash (author, relativeDate) subject [refs]" - // Highlight hash (first 7+ chars until space) - const hashEnd = commit.shortHash.length; - editor.addOverlay(bufferId, "gitlog", lineStart, lineStart + hashEnd, { - fg: colors.hash, - }); - - // Highlight author name (inside parentheses) - const authorPattern = "(" + commit.author + ","; - const authorStartInLine = line.indexOf(authorPattern); - if (authorStartInLine >= 0) { - const authorStart = lineStart + authorStartInLine + 1; // skip "(" - const authorEnd = authorStart + commit.author.length; - editor.addOverlay(bufferId, "gitlog", authorStart, authorEnd, { - fg: colors.author, - }); - } +/** + * Synchronous detail refresh: render from cache if we have it, otherwise + * a "loading…" placeholder. Never spawns git. Called immediately on every + * selection change so the user sees instant feedback even while the real + * `git show` is debounced. + * + * Returns the commit that needs fetching (cache miss) or null (cache hit + * or no commit selected) so the caller can decide whether to spawn. + */ +function refreshDetailImmediate(): GitCommit | null { + if (state.groupId === null) return null; + if (state.commits.length === 0) { + renderDetailPlaceholder(editor.t("status.no_commits")); + return null; + } + const idx = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1)); + const commit = state.commits[idx]; + if (!commit) return null; - // Highlight relative date - const datePattern = ", " + commit.relativeDate + ")"; - const dateStartInLine = line.indexOf(datePattern); - if (dateStartInLine >= 0) { - const dateStart = lineStart + dateStartInLine + 2; // skip ", " - const dateEnd = dateStart + commit.relativeDate.length; - editor.addOverlay(bufferId, "gitlog", dateStart, dateEnd, { - fg: colors.date, - }); - } + if (state.detailCache && state.detailCache.hash === commit.hash) { + renderDetailForCommit(commit, state.detailCache.output); + return null; + } - // Highlight refs (branches/tags) at end of line if present - if (gitLogState.options.showRefs && commit.refs) { - const refsStartInLine = line.lastIndexOf(commit.refs); - if (refsStartInLine >= 0) { - const refsStart = lineStart + refsStartInLine; - const refsEnd = refsStart + commit.refs.length; - - // Determine color based on ref type - let refColor = colors.branch; - if (commit.refs.includes("tag:")) { - refColor = colors.tag; - } else if (commit.refs.includes("origin/") || commit.refs.includes("remote")) { - refColor = colors.remote; - } - - editor.addOverlay(bufferId, "gitlog", refsStart, refsEnd, { - fg: refColor, - bold: true, - }); - } - } + renderDetailPlaceholder( + editor.t("status.loading_commit", { hash: commit.shortHash }) + ); + return commit; +} - byteOffset += line.length + 1; - } +/** + * Spawn `git show` for `commit` and render the result. Tagged with + * `pendingDetailId` so a newer selection supersedes in-flight fetches. + */ +async function fetchAndRenderDetail(commit: GitCommit): Promise { + const myId = ++state.pendingDetailId; + const output = await fetchCommitShow(editor, commit.hash); + if (myId !== state.pendingDetailId) return; + if (state.groupId === null) return; + state.detailCache = { hash: commit.hash, output }; + // Only render if the current selection is still this commit — a rapid + // Up/Down burst might have moved on before we got here. + const currentIdx = Math.max( + 0, + Math.min(state.selectedIndex, state.commits.length - 1) + ); + if (state.commits[currentIdx]?.hash !== commit.hash) return; + renderDetailForCommit(commit, output); } -function updateGitLogView(): void { - if (gitLogState.bufferId !== null) { - const entries = buildGitLogEntries(); - gitLogState.cachedContent = entriesToContent(entries); - editor.setVirtualBufferContent(gitLogState.bufferId, entries); - applyGitLogHighlighting(); - } +/** + * Combined synchronous + asynchronous refresh used by open/refresh paths + * where there's no burst of events to collapse. + */ +async function refreshDetail(): Promise { + const pending = refreshDetailImmediate(); + if (pending) await fetchAndRenderDetail(pending); } // ============================================================================= -// Commit Detail View +// Selection tracking — keeps `state.selectedIndex` in sync with the log +// panel's native cursor so the highlight and detail stay consistent. // ============================================================================= -// Parse diff line to extract file and line information -interface DiffContext { - currentFile: string | null; - currentHunkNewStart: number; - currentHunkNewLine: number; // Current line within the new file +function selectedCommit(): GitCommit | null { + if (state.commits.length === 0) return null; + const i = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1)); + return state.commits[i] ?? null; } -function buildCommitDetailEntries(commit: GitCommit, showOutput: string): TextPropertyEntry[] { - const entries: TextPropertyEntry[] = []; - const lines = showOutput.split("\n"); - - // Track diff context for file/line navigation - const diffContext: DiffContext = { - currentFile: null, - currentHunkNewStart: 0, - currentHunkNewLine: 0, - }; - - for (const line of lines) { - let lineType = "text"; - const properties: Record = { type: lineType }; - - // Detect diff file header: diff --git a/path b/path - const diffHeaderMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/); - if (diffHeaderMatch) { - diffContext.currentFile = diffHeaderMatch[2]; // Use the 'b' (new) file path - diffContext.currentHunkNewStart = 0; - diffContext.currentHunkNewLine = 0; - lineType = "diff-header"; - properties.type = lineType; - properties.file = diffContext.currentFile; - } - // Detect +++ line (new file path) - else if (line.startsWith("+++ b/")) { - diffContext.currentFile = line.slice(6); - lineType = "diff-header"; - properties.type = lineType; - properties.file = diffContext.currentFile; - } - // Detect hunk header: @@ -old,count +new,count @@ - else if (line.startsWith("@@")) { - lineType = "diff-hunk"; - const hunkMatch = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); - if (hunkMatch) { - diffContext.currentHunkNewStart = parseInt(hunkMatch[1], 10); - diffContext.currentHunkNewLine = diffContext.currentHunkNewStart; - } - properties.type = lineType; - properties.file = diffContext.currentFile; - properties.line = diffContext.currentHunkNewStart; - } - // Addition line - else if (line.startsWith("+") && !line.startsWith("+++")) { - lineType = "diff-add"; - properties.type = lineType; - properties.file = diffContext.currentFile; - properties.line = diffContext.currentHunkNewLine; - diffContext.currentHunkNewLine++; - } - // Deletion line - else if (line.startsWith("-") && !line.startsWith("---")) { - lineType = "diff-del"; - properties.type = lineType; - properties.file = diffContext.currentFile; - // Deletion lines don't advance the new file line counter - } - // Context line (unchanged) - else if (line.startsWith(" ") && diffContext.currentFile && diffContext.currentHunkNewLine > 0) { - lineType = "diff-context"; - properties.type = lineType; - properties.file = diffContext.currentFile; - properties.line = diffContext.currentHunkNewLine; - diffContext.currentHunkNewLine++; - } - // Other diff header lines - else if (line.startsWith("index ") || line.startsWith("--- ")) { - lineType = "diff-header"; - properties.type = lineType; - } - // Commit header lines - else if (line.startsWith("commit ")) { - lineType = "header"; - properties.type = lineType; - const hashMatch = line.match(/^commit ([a-f0-9]+)/); - if (hashMatch) { - properties.hash = hashMatch[1]; - } - } - else if (line.startsWith("Author:")) { - lineType = "meta"; - properties.type = lineType; - properties.field = "author"; - } - else if (line.startsWith("Date:")) { - lineType = "meta"; - properties.type = lineType; - properties.field = "date"; - } - - entries.push({ - text: `${line}\n`, - properties: properties, - }); - } - - // Footer with help - entries.push({ - text: "\n", - properties: { type: "blank" }, - }); - entries.push({ - text: editor.t("panel.detail_footer") + "\n", - properties: { type: "footer" }, - }); - - return entries; -} - -function applyCommitDetailHighlighting(): void { - if (commitDetailState.bufferId === null) return; - - const bufferId = commitDetailState.bufferId; - - // Clear existing overlays - editor.clearNamespace(bufferId, "gitdetail"); - - // Use cached content (getBufferText doesn't work for virtual buffers) - const content = commitDetailState.cachedContent; - if (!content) return; - const lines = content.split("\n"); - - let byteOffset = 0; - - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - const lineStart = byteOffset; - const lineEnd = byteOffset + line.length; - - // Highlight diff additions (green) - if (line.startsWith("+") && !line.startsWith("+++")) { - editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, { - fg: colors.diffAdd, - }); - } - // Highlight diff deletions (red) - else if (line.startsWith("-") && !line.startsWith("---")) { - editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, { - fg: colors.diffDel, - }); - } - // Highlight hunk headers (cyan/blue) - else if (line.startsWith("@@")) { - editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, { - fg: colors.diffHunk, - bold: true, - }); - } - // Highlight commit hash in "commit " line (git show format) - else if (line.startsWith("commit ")) { - const hashMatch = line.match(/^commit ([a-f0-9]+)/); - if (hashMatch) { - const hashStart = lineStart + 7; // "commit " is 7 chars - editor.addOverlay(bufferId, "gitdetail", hashStart, hashStart + hashMatch[1].length, { - fg: colors.hash, - bold: true, - }); - } - } - // Highlight author line - else if (line.startsWith("Author:")) { - editor.addOverlay(bufferId, "gitdetail", lineStart + 8, lineEnd, { - fg: colors.author, - }); - } - // Highlight date line - else if (line.startsWith("Date:")) { - editor.addOverlay(bufferId, "gitdetail", lineStart + 6, lineEnd, { - fg: colors.date, - }); - } - // Highlight diff file headers - else if (line.startsWith("diff --git")) { - editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, { - fg: colors.header, - bold: true, - }); - } - - byteOffset += line.length + 1; - } +function indexFromCursorByte(bytePos: number): number { + // No header row — row 0 is commit 0. + const idx = rowFromByte(bytePos); + if (idx < 0) return 0; + if (idx >= state.commits.length) return state.commits.length - 1; + return idx; } // ============================================================================= -// Public Commands - Git Log +// Commands // ============================================================================= -async function show_git_log() : Promise { - if (gitLogState.isOpen) { - editor.setStatus(editor.t("status.already_open")); +async function show_git_log(): Promise { + if (state.isOpen) { + // Already open — pull the existing tab to the front instead of + // bailing out with a status message. + if (state.groupId !== null) { + editor.focusBufferGroupPanel(state.groupId, "log"); + } return; } - editor.setStatus(editor.t("status.loading")); - // Store the current split ID and buffer ID before opening git log - gitLogState.splitId = editor.getActiveSplitId(); - gitLogState.sourceBufferId = editor.getActiveBufferId(); - - // Fetch commits - gitLogState.commits = await fetchGitLog(); - - if (gitLogState.commits.length === 0) { + state.commits = await fetchGitLog(editor); + if (state.commits.length === 0) { editor.setStatus(editor.t("status.no_commits")); - gitLogState.splitId = null; return; } - // Build entries and cache content for highlighting - const entries = buildGitLogEntries(); - gitLogState.cachedContent = entriesToContent(entries); - - // Create virtual buffer in the current split (replacing current buffer) - const result = await editor.createVirtualBufferInExistingSplit({ - name: "*Git Log*", - mode: "git-log", - readOnly: true, - entries: entries, - splitId: gitLogState.splitId!, - showLineNumbers: false, - showCursors: true, - editingDisabled: true, - }); - - if (result !== null) { - gitLogState.isOpen = true; - gitLogState.bufferId = result.bufferId; - - // Apply syntax highlighting - applyGitLogHighlighting(); - - editor.setStatus(editor.t("status.log_ready", { count: String(gitLogState.commits.length) })); - editor.debug("Git log panel opened"); - } else { - gitLogState.splitId = null; - editor.setStatus(editor.t("status.failed_open")); - } + // `createBufferGroup` is not currently included in the generated + // `EditorAPI` type (it's a runtime-only binding, same as in audit_mode), + // so we cast to `any` to keep the type checker happy. + const group = await (editor as any).createBufferGroup( + "*Git Log*", + "git-log", + GROUP_LAYOUT + ); + state.groupId = group.groupId as number; + state.logBufferId = (group.panels["log"] as number | undefined) ?? null; + state.detailBufferId = (group.panels["detail"] as number | undefined) ?? null; + state.toolbarBufferId = (group.panels["toolbar"] as number | undefined) ?? null; + state.selectedIndex = 0; + state.detailCache = null; + state.isOpen = true; + + // The log panel owns a native cursor so j/k/Up/Down navigate commits, + // and the detail panel also gets a cursor so diff lines can be clicked + // / traversed before pressing Enter to open a file. + if (state.logBufferId !== null) { + editor.setBufferShowCursors(state.logBufferId, true); + } + if (state.detailBufferId !== null) { + editor.setBufferShowCursors(state.detailBufferId, true); + // Wrap long lines in the detail panel — git diffs often exceed the + // 40% split width, and horizontal scrolling a commit is awkward. + editor.setLineWrap(state.detailBufferId, null, true); + // Per-panel mode: the group was created with "git-log" which applies + // to the initially-focused panel (log). The detail panel's mode is + // set when we focus into it. + } + + renderToolbar(); + renderLog(); + // Position the cursor on the first commit (row 0 now that the header + // row is gone). + if (state.logBufferId !== null && state.commits.length > 0) { + editor.setBufferCursor(state.logBufferId, 0); + } + await refreshDetail(); + + if (state.groupId !== null) { + editor.focusBufferGroupPanel(state.groupId, "log"); + } + editor.on("cursor_moved", "on_git_log_cursor_moved"); + editor.on("mouse_click", "on_git_log_toolbar_click"); + editor.on("resize", "on_git_log_resize"); + editor.on("buffer_closed", "on_git_log_buffer_closed"); + + editor.setStatus( + editor.t("status.log_ready", { count: String(state.commits.length) }) + ); } registerHandler("show_git_log", show_git_log); -function git_log_close() : void { - if (!gitLogState.isOpen) { - return; - } - - // Restore the original buffer in the split - if (gitLogState.splitId !== null && gitLogState.sourceBufferId !== null) { - editor.setSplitBuffer(gitLogState.splitId, gitLogState.sourceBufferId); - } +/** Reset all state + unsubscribe. Idempotent; safe to call from either + * path (user-initiated close or externally-closed group via the tab's + * close button, which triggers `buffer_closed`). */ +function git_log_cleanup(): void { + if (!state.isOpen) return; + editor.off("cursor_moved", "on_git_log_cursor_moved"); + editor.off("mouse_click", "on_git_log_toolbar_click"); + editor.off("resize", "on_git_log_resize"); + editor.off("buffer_closed", "on_git_log_buffer_closed"); + state.isOpen = false; + state.groupId = null; + state.logBufferId = null; + state.detailBufferId = null; + state.toolbarBufferId = null; + state.toolbarButtons = []; + state.commits = []; + state.selectedIndex = 0; + state.detailCache = null; +} - // Close the git log buffer (it's no longer displayed) - if (gitLogState.bufferId !== null) { - editor.closeBuffer(gitLogState.bufferId); +function git_log_close(): void { + if (!state.isOpen) return; + const groupId = state.groupId; + git_log_cleanup(); + if (groupId !== null) { + editor.closeBufferGroup(groupId); } - - gitLogState.isOpen = false; - gitLogState.bufferId = null; - gitLogState.splitId = null; - gitLogState.sourceBufferId = null; - gitLogState.commits = []; editor.setStatus(editor.t("status.closed")); } registerHandler("git_log_close", git_log_close); -// Cursor moved handler for git log - update highlighting and status -function on_git_log_cursor_moved(data: { - buffer_id: number; - cursor_id: number; - old_position: number; - new_position: number; -}): void { - // Only handle cursor movement in our git log buffer - if (gitLogState.bufferId === null || data.buffer_id !== gitLogState.bufferId) { - return; - } - - // Re-apply highlighting to update cursor line highlight - applyGitLogHighlighting(); - - // Get cursor line to show status - const cursorLine = editor.getCursorLine(); - const headerLines = 1; - const commitIndex = cursorLine - headerLines; - - if (commitIndex >= 0 && commitIndex < gitLogState.commits.length) { - editor.setStatus(editor.t("status.commit_position", { current: String(commitIndex + 1), total: String(gitLogState.commits.length) })); +function on_git_log_buffer_closed(data: { buffer_id: number }): void { + if (!state.isOpen) return; + if ( + data.buffer_id === state.logBufferId || + data.buffer_id === state.detailBufferId || + data.buffer_id === state.toolbarBufferId + ) { + git_log_cleanup(); } } -registerHandler("on_git_log_cursor_moved", on_git_log_cursor_moved); - -// Register cursor movement handler -editor.on("cursor_moved", "on_git_log_cursor_moved"); - -async function git_log_refresh() : Promise { - if (!gitLogState.isOpen) return; +registerHandler("on_git_log_buffer_closed", on_git_log_buffer_closed); +async function git_log_refresh(): Promise { + if (!state.isOpen) return; editor.setStatus(editor.t("status.refreshing")); - gitLogState.commits = await fetchGitLog(); - updateGitLogView(); - editor.setStatus(editor.t("status.refreshed", { count: String(gitLogState.commits.length) })); + state.commits = await fetchGitLog(editor); + state.detailCache = null; + if (state.selectedIndex >= state.commits.length) { + state.selectedIndex = Math.max(0, state.commits.length - 1); + } + renderLog(); + await refreshDetail(); + editor.setStatus( + editor.t("status.refreshed", { count: String(state.commits.length) }) + ); } registerHandler("git_log_refresh", git_log_refresh); -// Helper function to get commit at current cursor position -function getCommitAtCursor(): GitCommit | null { - if (gitLogState.bufferId === null) return null; - - // Use text properties to find which commit the cursor is on - // This is more reliable than line number calculation - const props = editor.getTextPropertiesAtCursor(gitLogState.bufferId); - - if (props.length > 0) { - const prop = props[0]; - // Check if cursor is on a commit line (has type "commit" and index) - if (prop.type === "commit" && typeof prop.index === "number") { - const index = prop.index as number; - if (index >= 0 && index < gitLogState.commits.length) { - return gitLogState.commits[index]; - } - } - // Also support finding commit by hash (alternative lookup) - if (prop.hash && typeof prop.hash === "string") { - return gitLogState.commits.find(c => c.hash === prop.hash) || null; - } - } - - return null; -} - -async function git_log_show_commit() : Promise { - if (!gitLogState.isOpen || gitLogState.commits.length === 0) return; - if (gitLogState.splitId === null) return; - - const commit = getCommitAtCursor(); +function git_log_copy_hash(): void { + const commit = selectedCommit(); if (!commit) { editor.setStatus(editor.t("status.move_to_commit")); return; } + editor.copyToClipboard(commit.hash); + editor.setStatus( + editor.t("status.hash_copied", { + short: commit.shortHash, + full: commit.hash, + }) + ); +} +registerHandler("git_log_copy_hash", git_log_copy_hash); - editor.setStatus(editor.t("status.loading_commit", { hash: commit.shortHash })); - - // Fetch full commit info using git show (includes header and diff) - const showOutput = await fetchCommitDiff(commit.hash); - - // Build entries using raw git show output - const entries = buildCommitDetailEntries(commit, showOutput); - - // Cache content for highlighting (getBufferText doesn't work for virtual buffers) - commitDetailState.cachedContent = entriesToContent(entries); - - // Create virtual buffer in the current split (replacing git log view) - const result = await editor.createVirtualBufferInExistingSplit({ - name: `*Commit: ${commit.shortHash}*`, - mode: "git-commit-detail", - readOnly: true, - entries: entries, - splitId: gitLogState.splitId!, - showLineNumbers: false, // Disable line numbers for cleaner diff view - showCursors: true, - editingDisabled: true, - }); - - if (result !== null) { - commitDetailState.isOpen = true; - commitDetailState.bufferId = result.bufferId; - commitDetailState.splitId = gitLogState.splitId; - commitDetailState.commit = commit; - - // Apply syntax highlighting - applyCommitDetailHighlighting(); +/** Is the detail panel the currently-focused buffer? */ +function isDetailFocused(): boolean { + return ( + state.detailBufferId !== null && + editor.getActiveBufferId() === state.detailBufferId + ); +} - editor.setStatus(editor.t("status.commit_ready", { hash: commit.shortHash })); +function git_log_tab(): void { + if (state.groupId === null) return; + if (isDetailFocused()) { + editor.focusBufferGroupPanel(state.groupId, "log"); } else { - editor.setStatus(editor.t("status.failed_open_details")); + editor.focusBufferGroupPanel(state.groupId, "detail"); + const commit = selectedCommit(); + if (commit) editor.setStatus(detailFooter(commit.shortHash)); } } -registerHandler("git_log_show_commit", git_log_show_commit); +registerHandler("git_log_tab", git_log_tab); -function git_log_copy_hash() : void { - if (!gitLogState.isOpen || gitLogState.commits.length === 0) return; - - const commit = getCommitAtCursor(); - if (!commit) { - editor.setStatus(editor.t("status.move_to_commit")); +/** + * Enter: on the log panel jumps focus into the detail panel; on the detail + * panel opens the file at the cursor position (if any). + */ +function git_log_enter(): void { + if (state.groupId === null) return; + if (isDetailFocused()) { + git_log_detail_open_file(); return; } + editor.focusBufferGroupPanel(state.groupId, "detail"); + const commit = selectedCommit(); + if (commit) editor.setStatus(detailFooter(commit.shortHash)); +} +registerHandler("git_log_enter", git_log_enter); - // Copy hash to clipboard - editor.copyToClipboard(commit.hash); - editor.setStatus(editor.t("status.hash_copied", { short: commit.shortHash, full: commit.hash })); +/** q/Escape: closes the entire log group from any panel. */ +function git_log_q(): void { + if (state.groupId === null) return; + git_log_close(); } -registerHandler("git_log_copy_hash", git_log_copy_hash); +registerHandler("git_log_q", git_log_q); // ============================================================================= -// Public Commands - Commit Detail +// Detail panel — open file at commit // ============================================================================= -function git_commit_detail_close() : void { - if (!commitDetailState.isOpen) { - return; - } - - // Go back to the git log view by restoring the git log buffer - if (commitDetailState.splitId !== null && gitLogState.bufferId !== null) { - editor.setSplitBuffer(commitDetailState.splitId, gitLogState.bufferId); - // Re-apply highlighting since we're switching back - applyGitLogHighlighting(); - } - - // Close the commit detail buffer (it's no longer displayed) - if (commitDetailState.bufferId !== null) { - editor.closeBuffer(commitDetailState.bufferId); - } - - commitDetailState.isOpen = false; - commitDetailState.bufferId = null; - commitDetailState.splitId = null; - commitDetailState.commit = null; - - editor.setStatus(editor.t("status.log_ready", { count: String(gitLogState.commits.length) })); -} -registerHandler("git_commit_detail_close", git_commit_detail_close); +async function git_log_detail_open_file(): Promise { + if (state.detailBufferId === null) return; + const commit = selectedCommit(); + if (!commit) return; -// Close file view and go back to commit detail -function git_file_view_close() : void { - if (!fileViewState.isOpen) { + const props = editor.getTextPropertiesAtCursor(state.detailBufferId); + if (props.length === 0) { + editor.setStatus(editor.t("status.move_to_diff")); return; } - - // Go back to the commit detail view by restoring the commit detail buffer - if (fileViewState.splitId !== null && commitDetailState.bufferId !== null) { - editor.setSplitBuffer(fileViewState.splitId, commitDetailState.bufferId); - // Re-apply highlighting since we're switching back - applyCommitDetailHighlighting(); - } - - // Close the file view buffer (it's no longer displayed) - if (fileViewState.bufferId !== null) { - editor.closeBuffer(fileViewState.bufferId); - } - - fileViewState.isOpen = false; - fileViewState.bufferId = null; - fileViewState.splitId = null; - fileViewState.filePath = null; - fileViewState.commitHash = null; - - if (commitDetailState.commit) { - editor.setStatus(editor.t("status.commit_ready", { hash: commitDetailState.commit.shortHash })); + const file = props[0].file as string | undefined; + const line = (props[0].line as number | undefined) ?? 1; + if (!file) { + editor.setStatus(editor.t("status.move_to_diff_with_context")); + return; } -} -registerHandler("git_file_view_close", git_file_view_close); -// Fetch file content at a specific commit -async function fetchFileAtCommit(commitHash: string, filePath: string): Promise { - const cwd = editor.getCwd(); + editor.setStatus( + editor.t("status.file_loading", { file, hash: commit.shortHash }) + ); const result = await editor.spawnProcess("git", [ "show", - `${commitHash}:${filePath}`, - ], cwd); - + `${commit.hash}:${file}`, + ]); if (result.exit_code !== 0) { - return null; + editor.setStatus( + editor.t("status.file_not_found", { file, hash: commit.shortHash }) + ); + return; } - return result.stdout; -} + const lines = result.stdout.split("\n"); + const entries: TextPropertyEntry[] = lines.map((l, i) => ({ + text: l + (i < lines.length - 1 ? "\n" : ""), + properties: { type: "content", line: i + 1 }, + })); -// Get language type from file extension -function getLanguageFromPath(filePath: string): string { - const ext = editor.pathExtname(filePath).toLowerCase(); - const extMap: Record = { - ".rs": "rust", - ".ts": "typescript", - ".tsx": "typescript", - ".js": "javascript", - ".jsx": "javascript", - ".py": "python", - ".go": "go", - ".c": "c", - ".cpp": "cpp", - ".h": "c", - ".hpp": "cpp", - ".java": "java", - ".rb": "ruby", - ".sh": "shell", - ".bash": "shell", - ".zsh": "shell", - ".toml": "toml", - ".yaml": "yaml", - ".yml": "yaml", - ".json": "json", - ".md": "markdown", - ".css": "css", - ".html": "html", - ".xml": "xml", - }; - return extMap[ext] || "text"; + // `*:*` matches the virtual-name convention the host uses + // to detect syntax from the trailing filename's extension. + const name = `*${commit.shortHash}:${file}*`; + const view = await editor.createVirtualBuffer({ + name, + mode: "git-log-file-view", + readOnly: true, + editingDisabled: true, + showLineNumbers: true, + entries, + }); + if (view) { + const byte = await editor.getLineStartPosition(Math.max(0, line - 1)); + if (byte !== null) editor.setBufferCursor(view.bufferId, byte); + editor.setStatus( + editor.t("status.file_view_ready", { + file, + hash: commit.shortHash, + line: String(line), + }) + ); + } else { + editor.setStatus(editor.t("status.failed_open_file", { file })); + } } +registerHandler("git_log_detail_open_file", git_log_detail_open_file); -// Keywords for different languages -const languageKeywords: Record = { - rust: ["fn", "let", "mut", "const", "pub", "use", "mod", "struct", "enum", "impl", "trait", "for", "while", "loop", "if", "else", "match", "return", "async", "await", "move", "self", "Self", "super", "crate", "where", "type", "static", "unsafe", "extern", "ref", "dyn", "as", "in", "true", "false"], - typescript: ["function", "const", "let", "var", "class", "interface", "type", "extends", "implements", "import", "export", "from", "async", "await", "return", "if", "else", "for", "while", "do", "switch", "case", "break", "continue", "new", "this", "super", "null", "undefined", "true", "false", "try", "catch", "finally", "throw", "typeof", "instanceof", "void", "delete", "in", "of", "static", "readonly", "private", "public", "protected", "abstract", "enum"], - javascript: ["function", "const", "let", "var", "class", "extends", "import", "export", "from", "async", "await", "return", "if", "else", "for", "while", "do", "switch", "case", "break", "continue", "new", "this", "super", "null", "undefined", "true", "false", "try", "catch", "finally", "throw", "typeof", "instanceof", "void", "delete", "in", "of", "static"], - python: ["def", "class", "if", "elif", "else", "for", "while", "try", "except", "finally", "with", "as", "import", "from", "return", "yield", "raise", "pass", "break", "continue", "and", "or", "not", "in", "is", "lambda", "None", "True", "False", "global", "nonlocal", "async", "await", "self"], - go: ["func", "var", "const", "type", "struct", "interface", "map", "chan", "if", "else", "for", "range", "switch", "case", "default", "break", "continue", "return", "go", "defer", "select", "import", "package", "nil", "true", "false", "make", "new", "len", "cap", "append", "copy", "delete", "panic", "recover"], -}; - -// Apply basic syntax highlighting to file view -function applyFileViewHighlighting(bufferId: number, content: string, filePath: string): void { - const language = getLanguageFromPath(filePath); - const keywords = languageKeywords[language] || []; - const lines = content.split("\n"); - - // Clear existing overlays - editor.clearNamespace(bufferId, "syntax"); - - let byteOffset = 0; - let inMultilineComment = false; - let inMultilineString = false; - - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - const lineStart = byteOffset; - - // Skip empty lines - if (line.trim() === "") { - byteOffset += line.length + 1; - continue; - } - - // Check for multiline comment start/end - if (language === "rust" || language === "c" || language === "cpp" || language === "java" || language === "javascript" || language === "typescript" || language === "go") { - if (line.includes("/*") && !line.includes("*/")) { - inMultilineComment = true; - } - if (inMultilineComment) { - editor.addOverlay(bufferId, "syntax", lineStart, lineStart + line.length, { - fg: colors.syntaxComment, - italic: true, - }); - if (line.includes("*/")) { - inMultilineComment = false; - } - byteOffset += line.length + 1; - continue; - } - } - - // Python multiline strings - if (language === "python" && (line.includes('"""') || line.includes("'''"))) { - const tripleQuote = line.includes('"""') ? '"""' : "'''"; - const firstIdx = line.indexOf(tripleQuote); - const secondIdx = line.indexOf(tripleQuote, firstIdx + 3); - if (firstIdx >= 0 && secondIdx < 0) { - inMultilineString = !inMultilineString; - } - } - if (inMultilineString) { - editor.addOverlay(bufferId, "syntax", lineStart, lineStart + line.length, { - fg: colors.syntaxString, - }); - byteOffset += line.length + 1; - continue; - } - - // Single-line comment detection - let commentStart = -1; - if (language === "rust" || language === "c" || language === "cpp" || language === "java" || language === "javascript" || language === "typescript" || language === "go") { - commentStart = line.indexOf("//"); - } else if (language === "python" || language === "shell" || language === "ruby" || language === "yaml" || language === "toml") { - commentStart = line.indexOf("#"); - } - - if (commentStart >= 0) { - editor.addOverlay(bufferId, "syntax", lineStart + commentStart, lineStart + line.length, { - fg: colors.syntaxComment, - italic: true, - }); - } - - // String highlighting (simple: find "..." and '...') - let i = 0; - while (i < line.length) { - const ch = line[i]; - if (ch === '"' || ch === "'") { - const quote = ch; - const start = i; - i++; - while (i < line.length && line[i] !== quote) { - if (line[i] === '\\') i++; // Skip escaped chars - i++; - } - if (i < line.length) i++; // Include closing quote - const end = i; - if (commentStart < 0 || start < commentStart) { - editor.addOverlay(bufferId, "syntax", lineStart + start, lineStart + end, { - fg: colors.syntaxString, - }); - } - } else { - i++; - } - } - - // Keyword highlighting - for (const keyword of keywords) { - const regex = new RegExp(`\\b${keyword}\\b`, "g"); - let match: RegExpExecArray | null; - while ((match = regex.exec(line)) !== null) { - const kwStart = match.index; - const kwEnd = kwStart + keyword.length; - // Don't highlight if inside comment - if (commentStart < 0 || kwStart < commentStart) { - editor.addOverlay(bufferId, "syntax", lineStart + kwStart, lineStart + kwEnd, { - fg: colors.syntaxKeyword, - bold: true, - }); - } - } - } - - // Number highlighting - const numberRegex = /\b\d+(\.\d+)?\b/g; - let numMatch: RegExpExecArray | null; - while ((numMatch = numberRegex.exec(line)) !== null) { - const numStart = numMatch.index; - const numEnd = numStart + numMatch[0].length; - if (commentStart < 0 || numStart < commentStart) { - editor.addOverlay(bufferId, "syntax", lineStart + numStart, lineStart + numEnd, { - fg: colors.syntaxNumber, - }); - } - } +// File-view mode so `q` closes the tab and returns to the group. +editor.defineMode( + "git-log-file-view", + [ + ["q", "git_log_file_view_close"], + ["Escape", "git_log_file_view_close"], + ], + true +); - byteOffset += line.length + 1; - } +function git_log_file_view_close(): void { + const id = editor.getActiveBufferId(); + if (id) editor.closeBuffer(id); } +registerHandler("git_log_file_view_close", git_log_file_view_close); -// Open file at the current diff line position - shows file as it was at that commit -async function git_commit_detail_open_file() : Promise { - if (!commitDetailState.isOpen || commitDetailState.bufferId === null) { - return; - } - - const commit = commitDetailState.commit; - if (!commit) { - editor.setStatus(editor.t("status.move_to_commit")); - return; - } +// ============================================================================= +// Cursor tracking — live-update the detail panel as the user scrolls through +// the commit list. +// ============================================================================= - // Get text properties at cursor position to find file/line info - const props = editor.getTextPropertiesAtCursor(commitDetailState.bufferId); - - if (props.length > 0) { - const file = props[0].file as string | undefined; - const line = props[0].line as number | undefined; - - if (file) { - editor.setStatus(editor.t("status.file_loading", { file, hash: commit.shortHash })); - - // Fetch file content at this commit - const content = await fetchFileAtCommit(commit.hash, file); - - if (content === null) { - editor.setStatus(editor.t("status.file_not_found", { file, hash: commit.shortHash })); - return; - } - - // Build entries for the virtual buffer - one entry per line for proper line tracking - const lines = content.split("\n"); - const entries: TextPropertyEntry[] = []; - - for (let i = 0; i < lines.length; i++) { - entries.push({ - text: lines[i] + (i < lines.length - 1 ? "\n" : ""), - properties: { type: "content", line: i + 1 }, - }); - } - - // Create a read-only virtual buffer with the file content - const result = await editor.createVirtualBufferInExistingSplit({ - name: `${file} @ ${commit.shortHash}`, - mode: "git-file-view", - readOnly: true, - entries: entries, - splitId: commitDetailState.splitId!, - showLineNumbers: true, - showCursors: true, - editingDisabled: true, - }); - - if (result !== null) { - // Track file view state so we can navigate back - fileViewState.isOpen = true; - fileViewState.bufferId = result.bufferId; - fileViewState.splitId = commitDetailState.splitId; - fileViewState.filePath = file; - fileViewState.commitHash = commit.hash; - - // Apply syntax highlighting based on file type - applyFileViewHighlighting(result.bufferId, content, file); - - const targetLine = line || 1; - editor.setStatus(editor.t("status.file_view_ready", { file, hash: commit.shortHash, line: String(targetLine) })); - } else { - editor.setStatus(editor.t("status.failed_open_file", { file })); - } - } else { - editor.setStatus(editor.t("status.move_to_diff_with_context")); - } - } else { - editor.setStatus(editor.t("status.move_to_diff")); - } +async function on_git_log_cursor_moved(data: { + buffer_id: number; + cursor_id: number; + old_position: number; + new_position: number; +}): Promise { + if (!state.isOpen) return; + // Only react to movement inside the log panel. + if (data.buffer_id !== state.logBufferId) return; + + // Map the cursor's byte offset to a commit index via the row-offset + // table built in `renderLog`. This avoids relying on `getCursorLine` + // which is not implemented for virtual buffers. + const idx = indexFromCursorByte(data.new_position); + if (idx === state.selectedIndex) return; + state.selectedIndex = idx; + + // Immediate feedback: update the log panel's selection highlight and + // either show the cached detail or a "loading" placeholder. Only the + // actual `git show` spawn is debounced below, so a burst of j/k events + // still feels responsive even though we collapse the fetches into one. + renderLog(); + const pending = refreshDetailImmediate(); + + const commit = state.commits[state.selectedIndex]; + if (commit) { + editor.setStatus( + editor.t("status.commit_position", { + current: String(state.selectedIndex + 1), + total: String(state.commits.length), + }) + ); + } + + if (!pending) return; + + // Debounce: bump the token, wait a beat, bail if a newer event has + // arrived. `git show` is expensive; a burst of cursor events (held + // j/k, PageDown) must collapse to one spawn. + const myId = ++state.pendingCursorMoveId; + await editor.delay(CURSOR_DEBOUNCE_MS); + if (myId !== state.pendingCursorMoveId) return; + if (!state.isOpen) return; + await fetchAndRenderDetail(pending); } -registerHandler("git_commit_detail_open_file", git_commit_detail_open_file); +registerHandler("on_git_log_cursor_moved", on_git_log_cursor_moved); // ============================================================================= -// Command Registration +// Command registration // ============================================================================= editor.registerCommand( @@ -1166,14 +763,12 @@ editor.registerCommand( "show_git_log", null ); - editor.registerCommand( "%cmd.git_log_close", "%cmd.git_log_close_desc", "git_log_close", null ); - editor.registerCommand( "%cmd.git_log_refresh", "%cmd.git_log_refresh_desc", @@ -1181,8 +776,4 @@ editor.registerCommand( null ); -// ============================================================================= -// Plugin Initialization -// ============================================================================= - -editor.debug("Git Log plugin initialized - Use 'Git Log' command to open"); +editor.debug("Git Log plugin initialized (modern buffer-group layout)"); diff --git a/crates/fresh-editor/plugins/lib/fresh.d.ts b/crates/fresh-editor/plugins/lib/fresh.d.ts index e89342bfa..ca1380afb 100644 --- a/crates/fresh-editor/plugins/lib/fresh.d.ts +++ b/crates/fresh-editor/plugins/lib/fresh.d.ts @@ -1413,7 +1413,7 @@ interface EditorAPI { /** * Define a buffer mode (takes bindings as array of [key, command] pairs) */ - defineMode(name: string, bindingsArr: string[][], readOnly?: boolean, allowTextInput?: boolean): boolean; + defineMode(name: string, bindingsArr: string[][], readOnly?: boolean, allowTextInput?: boolean, inheritNormalBindings?: boolean): boolean; /** * Set the global editor mode */ diff --git a/crates/fresh-editor/plugins/lib/git_history.ts b/crates/fresh-editor/plugins/lib/git_history.ts new file mode 100644 index 000000000..b59344c5d --- /dev/null +++ b/crates/fresh-editor/plugins/lib/git_history.ts @@ -0,0 +1,596 @@ +/// + +/** + * Shared git history rendering helpers used by the git log plugin and the + * review-diff plugin's branch review mode. + * + * All rendering uses theme-keyed colours (`syntax.keyword`, `editor.fg`, etc.) + * so the panels stay consistent with the editor's current theme. The entry + * builders produce `TextPropertyEntry[]` lists whose sub-ranges are styled + * via `inlineOverlays` — no separate imperative overlay pass is required. + */ + +// ============================================================================= +// Types +// ============================================================================= + +export interface GitCommit { + hash: string; + shortHash: string; + author: string; + authorEmail: string; + date: string; + relativeDate: string; + subject: string; + body: string; + refs: string; +} + +export interface FetchGitLogOptions { + /** Max commits to fetch (default: 200). */ + maxCommits?: number; + /** Optional revision range (e.g. "main..HEAD"). Defaults to HEAD. */ + range?: string; + /** Working directory. Defaults to `editor.getCwd()`. */ + cwd?: string; +} + +export interface BuildCommitLogEntriesOptions { + /** Index of the "selected" row — rendered with the selected-bg highlight. */ + selectedIndex?: number; + /** Optional header string (e.g. "Commits:"). `null` omits the header row. */ + header?: string | null; + /** Footer line (status hint). Omitted when null/undefined. */ + footer?: string | null; + /** Target width for padding column alignment (default 0 = no padding). */ + width?: number; + /** "log" property-type prefix for entries (default "log-commit"). */ + propertyType?: string; +} + +// ============================================================================= +// Theme keys +// ============================================================================= + +export const GIT_THEME = { + header: "syntax.keyword" as OverlayColorSpec, + separator: "ui.split_separator_fg" as OverlayColorSpec, + hash: "syntax.number" as OverlayColorSpec, + author: "syntax.function" as OverlayColorSpec, + date: "syntax.string" as OverlayColorSpec, + subject: "editor.fg" as OverlayColorSpec, + subjectMuted: "editor.line_number_fg" as OverlayColorSpec, + refBranch: "syntax.type" as OverlayColorSpec, + refRemote: "syntax.function" as OverlayColorSpec, + refTag: "syntax.number" as OverlayColorSpec, + refHead: "syntax.keyword" as OverlayColorSpec, + diffAdd: "editor.diff_add_bg" as OverlayColorSpec, + diffRemove: "editor.diff_remove_bg" as OverlayColorSpec, + diffAddFg: "diagnostic.info_fg" as OverlayColorSpec, + diffRemoveFg: "diagnostic.error_fg" as OverlayColorSpec, + diffHunk: "syntax.type" as OverlayColorSpec, + metaLabel: "editor.line_number_fg" as OverlayColorSpec, + selectionBg: "editor.selection_bg" as OverlayColorSpec, + sectionBg: "editor.current_line_bg" as OverlayColorSpec, + footer: "editor.line_number_fg" as OverlayColorSpec, +}; + +// ============================================================================= +// Author initials helper — compact "(AL)" / "(JD)" style label used in the +// aligned log view. Falls back to the raw author when no initials can be +// extracted. +// ============================================================================= + +export function authorInitials(author: string): string { + const cleaned = author.replace(/[<>].*/g, "").trim(); + const parts = cleaned.split(/\s+/).filter(p => p.length > 0); + if (parts.length === 0) return "??"; + if (parts.length === 1) { + return parts[0].slice(0, 2).toUpperCase(); + } + const first = parts[0][0] || "?"; + const last = parts[parts.length - 1][0] || "?"; + return (first + last).toUpperCase(); +} + +// ============================================================================= +// Commit fetching +// ============================================================================= + +export async function fetchGitLog( + editor: EditorAPI, + opts: FetchGitLogOptions = {} +): Promise { + const maxCommits = opts.maxCommits ?? 200; + const cwd = opts.cwd ?? editor.getCwd(); + const format = "%H%x00%h%x00%an%x00%ae%x00%ai%x00%ar%x00%D%x00%s%x00%b%x1e"; + const args = ["log", `--format=${format}`, `-n${maxCommits}`]; + if (opts.range) args.push(opts.range); + + const result = await editor.spawnProcess("git", args, cwd); + if (result.exit_code !== 0) return []; + + const commits: GitCommit[] = []; + const records = result.stdout.split("\x1e"); + for (const record of records) { + if (!record.trim()) continue; + const parts = record.split("\x00"); + if (parts.length < 8) continue; + commits.push({ + hash: parts[0].trim(), + shortHash: parts[1].trim(), + author: parts[2].trim(), + authorEmail: parts[3].trim(), + date: parts[4].trim(), + relativeDate: parts[5].trim(), + refs: parts[6].trim(), + subject: parts[7].trim(), + body: parts[8] ? parts[8].trim() : "", + }); + } + return commits; +} + +/** + * A single file's diff exceeding this line count is omitted from the + * rendered `git show` output. Generated files (lockfiles, bundled SVGs, + * minified JS) can produce megabyte-scale diffs that balloon the detail + * panel into hundreds of thousands of entries — slow to render and not + * useful to read. The stat header still lists the file so the user knows + * it changed; a footer tells them which ones were skipped. + */ +const MAX_DIFF_LINES_PER_FILE = 2000; + +export async function fetchCommitShow( + editor: EditorAPI, + hash: string, + cwd?: string +): Promise { + const workdir = cwd ?? editor.getCwd(); + + // numstat first — small output, lets us spot oversized files before + // pulling the full diff. + const numstatResult = await editor.spawnProcess( + "git", + ["show", "--numstat", "--format=", hash], + workdir + ); + const oversized: string[] = []; + if (numstatResult.exit_code === 0) { + for (const line of numstatResult.stdout.split("\n")) { + if (!line) continue; + // numstat format: "\t\t"; "-" for binary files. + const tab1 = line.indexOf("\t"); + const tab2 = tab1 >= 0 ? line.indexOf("\t", tab1 + 1) : -1; + if (tab1 < 0 || tab2 < 0) continue; + const addedStr = line.slice(0, tab1); + const removedStr = line.slice(tab1 + 1, tab2); + const path = line.slice(tab2 + 1); + const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) || 0; + const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10) || 0; + if (added + removed > MAX_DIFF_LINES_PER_FILE) { + oversized.push(path); + } + } + } + + // Stat + patch, excluding oversized paths. `:(exclude,top)` is rooted + // at the repo root so it matches regardless of git's cwd. + const showArgs = ["show", "--stat", "--patch", hash]; + if (oversized.length > 0) { + showArgs.push("--", "."); + for (const p of oversized) showArgs.push(`:(exclude,top)${p}`); + } + const result = await editor.spawnProcess("git", showArgs, workdir); + if (result.exit_code !== 0) return result.stderr || "(no output)"; + + if (oversized.length === 0) return result.stdout; + + const plural = oversized.length === 1 ? "" : "s"; + let footer = `\n[${oversized.length} large file${plural} omitted from diff (>${MAX_DIFF_LINES_PER_FILE} lines changed):\n`; + for (const p of oversized) footer += ` ${p}\n`; + footer += `Run \`git show ${hash.slice(0, 12)} -- \` to view.]\n`; + return result.stdout + footer; +} + +// ============================================================================= +// UTF-8 byte-length helper — the runtime's overlay offsets are in bytes, but +// JS strings are UTF-16. Colocated here so consumers don't have to redefine it. +// ============================================================================= + +export function byteLength(s: string): number { + let b = 0; + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code <= 0x7f) b += 1; + else if (code <= 0x7ff) b += 2; + else if (code >= 0xd800 && code <= 0xdfff) { + b += 4; + i++; + } else b += 3; + } + return b; +} + +// ============================================================================= +// Commit log entry building +// ============================================================================= + +/** + * Compute column widths for the aligned commit-log table. Returns widths for + * (hash, date, initials) columns. Subject and refs fill the remainder. + */ +function commitLogColumnWidths(commits: GitCommit[]): { + hashW: number; + dateW: number; + authorW: number; +} { + let hashW = 7; + let dateW = 10; + let authorW = 2; + for (const c of commits) { + if (c.shortHash.length > hashW) hashW = c.shortHash.length; + if (c.relativeDate.length > dateW) dateW = c.relativeDate.length; + const ini = authorInitials(c.author); + if (ini.length > authorW) authorW = ini.length; + } + // Clamp so a pathological author/date doesn't swallow the subject column. + if (hashW > 12) hashW = 12; + if (dateW > 16) dateW = 16; + if (authorW > 4) authorW = 4; + return { hashW, dateW, authorW }; +} + +/** + * Classify a git ref decoration tag so it can be coloured appropriately. + * Matches a single comma-separated entry from `%D` output, e.g. + * "HEAD -> main", "origin/main", "tag: v1.0". + */ +function refTokenColor(token: string): OverlayColorSpec { + const t = token.trim(); + if (t.startsWith("tag:")) return GIT_THEME.refTag; + if (t.startsWith("HEAD")) return GIT_THEME.refHead; + if (t.includes("/")) return GIT_THEME.refRemote; + return GIT_THEME.refBranch; +} + +/** + * Build a styled commit-log entry row with aligned columns. All styling uses + * `inlineOverlays` with theme keys — no imperative overlay pass needed. + */ +function buildCommitRowEntry( + commit: GitCommit, + index: number, + isSelected: boolean, + widths: { hashW: number; dateW: number; authorW: number }, + propertyType: string +): TextPropertyEntry { + const shortHash = commit.shortHash.padEnd(widths.hashW); + const date = commit.relativeDate.padEnd(widths.dateW); + const ini = authorInitials(commit.author).padEnd(widths.authorW); + + const prefix = " "; + let byte = byteLength(prefix); + let text = prefix; + const overlays: InlineOverlay[] = []; + + // Hash column + overlays.push({ + start: byte, + end: byte + byteLength(shortHash), + style: { fg: GIT_THEME.hash, bold: true }, + }); + text += shortHash; + byte += byteLength(shortHash); + + // Space + text += " "; + byte += 2; + + // Date column + overlays.push({ + start: byte, + end: byte + byteLength(date), + style: { fg: GIT_THEME.date }, + }); + text += date; + byte += byteLength(date); + + // Space + text += " "; + byte += 2; + + // Author initials in parentheses + const authorOpen = "("; + const authorClose = ")"; + text += authorOpen; + byte += byteLength(authorOpen); + overlays.push({ + start: byte, + end: byte + byteLength(ini), + style: { fg: GIT_THEME.author, bold: true }, + }); + text += ini; + byte += byteLength(ini); + text += authorClose; + byte += byteLength(authorClose); + + // Space + text += " "; + byte += 1; + + // Subject + overlays.push({ + start: byte, + end: byte + byteLength(commit.subject), + style: { fg: GIT_THEME.subject }, + }); + text += commit.subject; + byte += byteLength(commit.subject); + + // Refs (if any) — tokenise and colour each separately. %D returns a + // comma-separated list like "HEAD -> main, origin/main, tag: v1". + if (commit.refs) { + text += " "; + byte += 2; + const tokens = commit.refs.split(",").map(t => t.trim()).filter(t => t.length > 0); + for (let i = 0; i < tokens.length; i++) { + if (i > 0) { + text += " "; + byte += 1; + } + // "HEAD -> main" renders as two logical tokens inside one entry; + // treat the whole token as one coloured chunk for simplicity. + const t = tokens[i]; + const bracket = `[${t}]`; + overlays.push({ + start: byte, + end: byte + byteLength(bracket), + style: { fg: refTokenColor(t), bold: true }, + }); + text += bracket; + byte += byteLength(bracket); + } + } + + const finalText = text + "\n"; + + const style: Partial = isSelected + ? { bg: GIT_THEME.selectionBg, extendToLineEnd: true, bold: true } + : {}; + + return { + text: finalText, + properties: { + type: propertyType, + index, + hash: commit.hash, + shortHash: commit.shortHash, + author: commit.author, + date: commit.relativeDate, + subject: commit.subject, + refs: commit.refs, + }, + style, + inlineOverlays: overlays, + }; +} + +export function buildCommitLogEntries( + commits: GitCommit[], + opts: BuildCommitLogEntriesOptions = {} +): TextPropertyEntry[] { + const header = opts.header === undefined ? "Commits:" : opts.header; + const footer = opts.footer; + const selectedIndex = opts.selectedIndex ?? -1; + const propertyType = opts.propertyType ?? "log-commit"; + + const entries: TextPropertyEntry[] = []; + + if (header !== null) { + entries.push({ + text: header + "\n", + properties: { type: "log-header" }, + style: { fg: GIT_THEME.header, bold: true, underline: true }, + }); + } + + if (commits.length === 0) { + entries.push({ + text: " (no commits)\n", + properties: { type: "log-empty" }, + style: { fg: GIT_THEME.metaLabel, italic: true }, + }); + } else { + const widths = commitLogColumnWidths(commits); + for (let i = 0; i < commits.length; i++) { + entries.push( + buildCommitRowEntry(commits[i], i, i === selectedIndex, widths, propertyType) + ); + } + } + + if (footer) { + entries.push({ + text: "\n", + properties: { type: "log-blank" }, + }); + entries.push({ + text: footer + "\n", + properties: { type: "log-footer" }, + style: { fg: GIT_THEME.footer, italic: true }, + }); + } + + return entries; +} + +// ============================================================================= +// Commit detail (git show) entry building +// ============================================================================= + +interface DetailBuildContext { + currentFile: string | null; + currentNewLine: number; +} + +/** + * Style a single line from `git show --stat --patch` output as a styled + * TextPropertyEntry with inlineOverlays. Tracks file/line context for click + * navigation. + */ +function buildDetailLineEntry( + line: string, + ctx: DetailBuildContext +): TextPropertyEntry { + const props: Record = { type: "detail-line" }; + const overlays: InlineOverlay[] = []; + let lineStyle: Partial = {}; + + // "diff --git a/... b/..." + const diffHeader = line.match(/^diff --git a\/(.+) b\/(.+)$/); + if (diffHeader) { + ctx.currentFile = diffHeader[2]; + ctx.currentNewLine = 0; + props.type = "detail-diff-header"; + props.file = ctx.currentFile; + lineStyle = { fg: GIT_THEME.header, bold: true }; + } else if (line.startsWith("+++ b/")) { + ctx.currentFile = line.slice(6); + props.type = "detail-diff-header"; + props.file = ctx.currentFile; + lineStyle = { fg: GIT_THEME.header, bold: true }; + } else if (line.startsWith("+++ ") || line.startsWith("--- ") || line.startsWith("index ")) { + props.type = "detail-diff-header"; + lineStyle = { fg: GIT_THEME.subjectMuted }; + } else if (line.startsWith("@@")) { + const hunkMatch = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunkMatch) ctx.currentNewLine = parseInt(hunkMatch[1], 10); + props.type = "detail-hunk-header"; + props.file = ctx.currentFile; + props.line = ctx.currentNewLine; + lineStyle = { fg: GIT_THEME.diffHunk, bold: true, extendToLineEnd: true }; + } else if (line.startsWith("+") && !line.startsWith("+++")) { + props.type = "detail-add"; + props.file = ctx.currentFile; + props.line = ctx.currentNewLine; + ctx.currentNewLine++; + lineStyle = { fg: GIT_THEME.diffAddFg, bg: GIT_THEME.diffAdd, extendToLineEnd: true }; + } else if (line.startsWith("-") && !line.startsWith("---")) { + props.type = "detail-remove"; + props.file = ctx.currentFile; + lineStyle = { fg: GIT_THEME.diffRemoveFg, bg: GIT_THEME.diffRemove, extendToLineEnd: true }; + } else if (line.startsWith(" ") && ctx.currentFile && ctx.currentNewLine > 0) { + props.type = "detail-context"; + props.file = ctx.currentFile; + props.line = ctx.currentNewLine; + ctx.currentNewLine++; + } else if (line.startsWith("commit ")) { + props.type = "detail-commit-line"; + const hashMatch = line.match(/^commit ([a-f0-9]+)/); + if (hashMatch) { + props.hash = hashMatch[1]; + // Colour just "commit" and the hash chunk separately. + const commitWord = "commit "; + overlays.push({ + start: 0, + end: byteLength(commitWord), + style: { fg: GIT_THEME.metaLabel, bold: true }, + }); + overlays.push({ + start: byteLength(commitWord), + end: byteLength(commitWord) + byteLength(hashMatch[1]), + style: { fg: GIT_THEME.hash, bold: true }, + }); + } + } else if (/^(Author|Date|Commit|Merge|AuthorDate|CommitDate):/.test(line)) { + const colonIdx = line.indexOf(":"); + props.type = "detail-meta"; + overlays.push({ + start: 0, + end: byteLength(line.slice(0, colonIdx + 1)), + style: { fg: GIT_THEME.metaLabel, bold: true }, + }); + const fieldKey = line.slice(0, colonIdx).toLowerCase(); + if (fieldKey === "author") { + overlays.push({ + start: byteLength(line.slice(0, colonIdx + 1)), + end: byteLength(line), + style: { fg: GIT_THEME.author }, + }); + } else if (fieldKey.includes("date")) { + overlays.push({ + start: byteLength(line.slice(0, colonIdx + 1)), + end: byteLength(line), + style: { fg: GIT_THEME.date }, + }); + } + } + + return { + text: line + "\n", + properties: props, + style: lineStyle, + inlineOverlays: overlays, + }; +} + +/** + * Build the entries for a commit detail view — a colourful replay of + * `git show --stat --patch`. The commit message body is already reflowed + * by `fetchCommitShow`; stat lines and diff lines pass through unchanged. + */ +export function buildCommitDetailEntries( + commit: GitCommit | null, + showOutput: string, + opts: { footer?: string | null } = {} +): TextPropertyEntry[] { + const entries: TextPropertyEntry[] = []; + + if (commit) { + entries.push({ + text: `${commit.shortHash} ${commit.subject}\n`, + properties: { type: "detail-title", hash: commit.hash }, + style: { fg: GIT_THEME.header, bold: true, underline: true }, + }); + } + + const ctx: DetailBuildContext = { currentFile: null, currentNewLine: 0 }; + for (const line of showOutput.split("\n")) { + entries.push(buildDetailLineEntry(line, ctx)); + } + + const footer = opts.footer; + if (footer) { + entries.push({ + text: "\n", + properties: { type: "detail-blank" }, + }); + entries.push({ + text: footer + "\n", + properties: { type: "detail-footer" }, + style: { fg: GIT_THEME.footer, italic: true }, + }); + } + + return entries; +} + +// ============================================================================= +// Placeholder entries shown in the detail panel while no commit has been +// loaded yet (e.g. during initial render or when the log is empty). +// ============================================================================= + +export function buildDetailPlaceholderEntries(message: string): TextPropertyEntry[] { + return [ + { + text: "\n", + properties: { type: "detail-blank" }, + }, + { + text: " " + message + "\n", + properties: { type: "detail-placeholder" }, + style: { fg: GIT_THEME.metaLabel, italic: true }, + }, + ]; +} diff --git a/crates/fresh-editor/src/app/buffer_groups.rs b/crates/fresh-editor/src/app/buffer_groups.rs index c5ed181a2..3aa174964 100644 --- a/crates/fresh-editor/src/app/buffer_groups.rs +++ b/crates/fresh-editor/src/app/buffer_groups.rs @@ -15,9 +15,22 @@ use std::collections::HashMap; #[serde(tag = "type")] enum LayoutDesc { #[serde(rename = "scrollable")] - Scrollable { id: String }, + Scrollable { + id: String, + /// Whether this panel responds to scroll events. Defaults to true + /// for scrollable panels. + scrollable: Option, + }, #[serde(rename = "fixed")] - Fixed { id: String, height: u16 }, + Fixed { + id: String, + height: u16, + /// Whether this panel responds to scroll events. Defaults to false + /// for fixed-height panels — their content is pinned to the panel + /// size, so mouse-wheel scroll is a no-op and no scrollbar is drawn. + /// Callers can override by passing `"scrollable": true`. + scrollable: Option, + }, #[serde(rename = "split")] Split { direction: String, // "h" or "v" @@ -103,14 +116,26 @@ impl super::Editor { } } - // Remove panel buffers from any split's open_buffers list - // (they were added during create_virtual_buffer). + // Remove panel buffers from every OTHER split's open_buffers AND + // keyed_states. create_virtual_buffer adds them to the active split + // when each was created; leaving them there makes the outer split + // carry a stale cursor entry for the panel buffer, which later + // collides with the panel's own view state in any lookup that + // scans split_view_states by buffer id. let hidden_panel_ids: Vec = panel_buffers.values().copied().collect(); - for (_leaf_id, vs) in self.split_view_states.iter_mut() { + let panel_leaf_ids: std::collections::HashSet = + panel_splits.values().copied().collect(); + for (leaf_id, vs) in self.split_view_states.iter_mut() { + if panel_leaf_ids.contains(leaf_id) { + // The panel's own view state needs its buffer. + continue; + } vs.open_buffers.retain(|t| match t { TabTarget::Buffer(b) => !hidden_panel_ids.contains(b), TabTarget::Group(_) => true, }); + vs.keyed_states + .retain(|bid, _| !hidden_panel_ids.contains(bid)); } // Add the group as a tab in the CURRENT split's tab bar and make it @@ -220,13 +245,14 @@ impl super::Editor { panel_buffers: &mut HashMap, ) -> Result { match desc { - LayoutDesc::Scrollable { id } => { + LayoutDesc::Scrollable { id, scrollable } => { + let scrollable = scrollable.unwrap_or(true); let buffer_id = self.create_virtual_buffer(format!("*{}*", id), mode.to_string(), true); - // Configure the buffer for panel use if let Some(state) = self.buffers.get_mut(&buffer_id) { state.show_cursors = false; state.editing_disabled = true; + state.scrollable = scrollable; state.margins.configure_for_line_numbers(false); } panel_buffers.insert(id.clone(), buffer_id); @@ -236,12 +262,18 @@ impl super::Editor { split_id: None, }) } - LayoutDesc::Fixed { id, height } => { + LayoutDesc::Fixed { + id, + height, + scrollable, + } => { + let scrollable = scrollable.unwrap_or(false); let buffer_id = self.create_virtual_buffer(format!("*{}*", id), mode.to_string(), true); if let Some(state) = self.buffers.get_mut(&buffer_id) { state.show_cursors = false; state.editing_disabled = true; + state.scrollable = scrollable; state.margins.configure_for_line_numbers(false); } panel_buffers.insert(id.clone(), buffer_id); @@ -386,6 +418,15 @@ impl super::Editor { vs.active_group_tab = Some(group_leaf_id); vs.focused_group_leaf = Some(inner_leaf); } + // Persist the choice on the SplitNode so a tab-away/back round + // trip restores the same panel — `activate_group_tab` reads + // this field when re-focusing the group. + if let Some(crate::view::split::SplitNode::Grouped { + active_inner_leaf, .. + }) = self.grouped_subtrees.get_mut(&group_leaf_id) + { + *active_inner_leaf = inner_leaf; + } // Transfer focus away from File Explorer (or any other context) // to the editor, since we're explicitly focusing a panel. self.key_context = crate::input::keybindings::KeyContext::Normal; @@ -481,6 +522,15 @@ fn fixed_height_of(node: &GroupLayoutNode) -> Option { } } +impl super::Editor { + /// Whether the given buffer is marked non-scrollable. Buffer-group + /// panels can set `scrollable: false` (and Fixed panels default to + /// it) so the mouse wheel is a no-op and no scrollbar is drawn. + pub(crate) fn is_non_scrollable_buffer(&self, buffer_id: BufferId) -> bool { + self.buffers.get(&buffer_id).is_some_and(|s| !s.scrollable) + } +} + /// Find the first scrollable leaf in the layout tree. fn find_first_scrollable_name(node: &GroupLayoutNode) -> Option { match node { diff --git a/crates/fresh-editor/src/app/buffer_management.rs b/crates/fresh-editor/src/app/buffer_management.rs index 4eabee538..f9b999523 100644 --- a/crates/fresh-editor/src/app/buffer_management.rs +++ b/crates/fresh-editor/src/app/buffer_management.rs @@ -1601,15 +1601,6 @@ impl Editor { buffer_id: BufferId, entries: Vec, ) -> Result<(), String> { - // Save current cursor position from split view state to preserve it after content update - let old_cursor_pos = self - .split_view_states - .values() - .find(|vs| vs.has_buffer(buffer_id)) - .and_then(|vs| vs.keyed_states.get(&buffer_id)) - .map(|bs| bs.cursors.primary().position) - .unwrap_or(0); - let state = self .buffers .get_mut(&buffer_id) @@ -1637,12 +1628,16 @@ impl Editor { // Set text properties state.text_properties = properties; - // Create inline overlays for the new content + // Create inline overlays for the new content. Build the full vec + // first and bulk-add it so the OverlayManager sorts exactly once; + // a per-overlay `add` re-sorts every time and is O(n² log n) for + // N entries (a big git-show diff can be ~500k overlays). { use crate::view::overlay::{Overlay, OverlayFace}; use fresh_core::overlay::OverlayNamespace; let inline_ns = OverlayNamespace::from_string("_inline".to_string()); + let mut new_overlays = Vec::with_capacity(collected_overlays.len()); for co in collected_overlays { let face = OverlayFace::from_options(&co.options); @@ -1656,22 +1651,35 @@ impl Editor { if let Some(url) = co.options.url { overlay.url = Some(url); } - state.overlays.add(overlay); + new_overlays.push(overlay); } + state.overlays.extend(new_overlays); } - // Preserve cursor position (clamped to new content length and snapped to char boundary) + // Each split keeps its own cursor; just clamp anything that fell + // past the new buffer end and snap to a char boundary. Don't read + // one split's cursor and write it into the others. let new_len = state.buffer.len(); - let clamped_pos = old_cursor_pos.min(new_len); - // Ensure cursor is at a valid UTF-8 character boundary (without moving if already valid) - let new_cursor_pos = state.buffer.snap_to_char_boundary(clamped_pos); - - // Update cursor in the split view state that has this buffer + // `state` is no longer used past this point — re-borrow `self.buffers` + // immutably for the snap and `self.split_view_states` mutably for the + // write. These are disjoint fields of `self`. + let buffer = &self + .buffers + .get(&buffer_id) + .expect("buffer still present") + .buffer; for view_state in self.split_view_states.values_mut() { - if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) { - buf_state.cursors.primary_mut().position = new_cursor_pos; - buf_state.cursors.primary_mut().anchor = None; - } + let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) else { + continue; + }; + buf_state.cursors.map(|cursor| { + let pos = cursor.position.min(new_len); + cursor.position = buffer.snap_to_char_boundary(pos); + if let Some(anchor) = cursor.anchor { + let clamped = anchor.min(new_len); + cursor.anchor = Some(buffer.snap_to_char_boundary(clamped)); + } + }); } Ok(()) @@ -2335,59 +2343,71 @@ impl Editor { }) }); - // Fall back: any visible buffer in the split, then any visible buffer at all. - let fallback_buffer: Option = if replacement_target.is_none() { - self.buffers - .keys() - .find(|&&bid| { - bid != id - && !self - .buffer_metadata - .get(&bid) - .map(|m| m.hidden_from_tabs) - .unwrap_or(false) - }) - .copied() - } else { - None - }; + // Any visible buffer other than the one being closed. Used as the + // general fallback (no LRU target or LRU points at a gone group). + let fallback_buffer: Option = self + .buffers + .keys() + .find(|&&bid| { + bid != id + && !self + .buffer_metadata + .get(&bid) + .map(|m| m.hidden_from_tabs) + .unwrap_or(false) + }) + .copied(); // Capture before the replacement computation — new_buffer() has the // side effect of calling set_active_buffer which changes active_buffer(). let closing_active = self.active_buffer() == id; - // Determine what to do: activate a group, switch to a buffer, or - // create a new empty buffer as last resort. + // Pick the BufferId that becomes the host split's `active_buffer`. + // When `return_to_group` is set, `active_buffer` is a housekeeping + // fiction — nothing renders it — so any existing buffer works; we + // just need to avoid synthesizing a phantom `[No Name]` when a real + // option exists. A synthetic buffer fires only when the editor has + // literally no other buffer left. let return_to_group = match replacement_target { Some(crate::view::split::TabTarget::Group(leaf)) => Some(leaf), _ => None, }; - let replacement_buffer = match replacement_target { - Some(crate::view::split::TabTarget::Buffer(bid)) => bid, - Some(crate::view::split::TabTarget::Group(group_leaf)) => { - // The group's inner panel buffer serves as the split leaf's - // underlying buffer. The group takes over the active target - // via activate_group_tab below, so this isn't user-visible. - self.grouped_subtrees - .get(&group_leaf) - .and_then(|node| { - if let crate::view::split::SplitNode::Grouped { - active_inner_leaf, .. - } = node - { - self.split_view_states - .get(active_inner_leaf) - .map(|vs| vs.active_buffer) - } else { - None - } - }) - .unwrap_or_else(|| fallback_buffer.unwrap_or_else(|| self.new_buffer())) - } - None => fallback_buffer.unwrap_or_else(|| self.new_buffer()), + + let direct_replacement = match replacement_target { + Some(crate::view::split::TabTarget::Buffer(bid)) => Some(bid), + _ => None, }; - let created_empty_buffer = replacement_target.is_none() && fallback_buffer.is_none(); + // Prefer a buffer already keyed in the host split: `switch_buffer` + // inserts a default BufferViewState for any new active_buffer, which + // for hidden panel buffers becomes a shadow entry (cursor=0) that + // the plugin-state snapshot could non-deterministically prefer over + // the panel split's authoritative copy. Picking something already + // keyed sidesteps that insert. (We clean up after the fact if a + // shadow does get created — see below.) + let already_keyed = return_to_group.and_then(|_| { + self.split_view_states + .get(&active_split)? + .keyed_states + .keys() + .find(|&&bid| bid != id) + .copied() + }); + + // Absolute last-resort pool for the Group case: any buffer at all, + // including hidden panel ones. The shadow cleanup below keeps + // those invisible. + let any_remaining = + return_to_group.and_then(|_| self.buffers.keys().copied().find(|&bid| bid != id)); + + let (replacement_buffer, created_empty_buffer) = match direct_replacement + .or(already_keyed) + .or(fallback_buffer) + .or(any_remaining) + { + Some(bid) => (bid, false), + None => (self.new_buffer(), true), + }; // Switch to replacement buffer BEFORE updating splits. // Only needed when the closing buffer is the one the user is @@ -2395,12 +2415,21 @@ impl Editor { if closing_active { self.set_active_buffer(replacement_buffer); - // When the replacement is a group's hidden inner panel buffer, - // undo the side effects of set_active_buffer adding it to the - // host split's tab list and focus history. - if return_to_group.is_some() { + // If we landed on a hidden panel buffer to fill the Group-case + // housekeeping slot, scrub the *visible* side effects + // (`open_buffers`, `focus_history`) so the panel buffer doesn't + // appear as a tab. The `keyed_states` entry `switch_buffer` + // inserted has to stay — `active_state()` requires + // `active_buffer ∈ keyed_states` — but it's harmless as long as + // the plugin-snapshot lookup skips it; see + // `snapshot_source_split` in `update_plugin_state_snapshot`. + let hidden = self + .buffer_metadata + .get(&replacement_buffer) + .is_some_and(|m| m.hidden_from_tabs); + if return_to_group.is_some() && hidden { + use crate::view::split::TabTarget; if let Some(vs) = self.split_view_states.get_mut(&active_split) { - use crate::view::split::TabTarget; vs.open_buffers .retain(|t| *t != TabTarget::Buffer(replacement_buffer)); vs.focus_history @@ -2450,6 +2479,15 @@ impl Editor { } } + // Notify plugins so they can reset any state tied to this buffer + // (e.g. a plugin that owns a buffer group clears its `isOpen` flag + // when the group is closed via the tab's close button rather than + // through the plugin's own close command). + self.plugin_manager.run_hook( + "buffer_closed", + fresh_core::hooks::HookArgs::BufferClosed { buffer_id: id }, + ); + Ok(()) } diff --git a/crates/fresh-editor/src/app/input.rs b/crates/fresh-editor/src/app/input.rs index 9cb88f4e1..cb8e083a5 100644 --- a/crates/fresh-editor/src/app/input.rs +++ b/crates/fresh-editor/src/app/input.rs @@ -1504,6 +1504,13 @@ impl Editor { .split_at_position(col, row) .unwrap_or_else(|| (self.split_manager.active_split(), self.active_buffer())); + // Panels marked non-scrollable (buffer-group toolbars/headers/footers + // default to this) swallow the wheel event — their content is pinned + // so scrolling would just shift the visible rows by one line. + if self.is_non_scrollable_buffer(buffer_id) { + return Ok(()); + } + // Check if this is a composite buffer - if so, use composite scroll if self.is_composite_buffer(buffer_id) { let max_row = self @@ -1613,39 +1620,31 @@ impl Editor { row: u16, delta: i32, ) -> AnyhowResult<()> { - let target_split = self + let (target_split, buffer_id) = self .split_at_position(col, row) - .map(|(id, _)| id) - .unwrap_or_else(|| self.split_manager.active_split()); + .unwrap_or_else(|| (self.split_manager.active_split(), self.active_buffer())); + + if self.is_non_scrollable_buffer(buffer_id) { + return Ok(()); + } if let Some(view_state) = self.split_view_states.get_mut(&target_split) { - // Don't scroll horizontally when line wrap is enabled + // Line wrap makes horizontal scroll a no-op. if view_state.viewport.line_wrap_enabled { return Ok(()); } let columns_to_scroll = delta.unsigned_abs() as usize; + let viewport = &mut view_state.viewport; if delta < 0 { - // Scroll left - view_state.viewport.left_column = view_state - .viewport - .left_column - .saturating_sub(columns_to_scroll); + viewport.left_column = viewport.left_column.saturating_sub(columns_to_scroll); } else { - // Scroll right - clamp to max_line_length_seen - let visible_width = view_state.viewport.width as usize; - let max_scroll = view_state - .viewport - .max_line_length_seen - .saturating_sub(visible_width); - let new_left = view_state - .viewport - .left_column - .saturating_add(columns_to_scroll); - view_state.viewport.left_column = new_left.min(max_scroll); + // No max_line_length_seen clamp: that value is stale between + // renders (often 0 before any h-scroll), pinning this at 0 + // even when long lines exist. Overshoot clips at render. + viewport.left_column = viewport.left_column.saturating_add(columns_to_scroll); } - // Skip ensure_visible so the scroll position isn't undone during render - view_state.viewport.set_skip_ensure_visible(); + viewport.set_skip_ensure_visible(); } Ok(()) @@ -2677,6 +2676,16 @@ impl Editor { ); } + // Fixed buffer-group panels (toolbars/headers/footers) aren't + // interactive targets: focusing them would let arrow keys move an + // invisible cursor and scroll the pinned content. Swallow the click + // after the plugin hook has had a chance to observe it. Scrollable + // group panels still accept the click (focus routes to them) even + // when their cursor is hidden. + if self.is_non_scrollable_buffer(buffer_id) { + return Ok(()); + } + // Focus this split (handles terminal mode exit, tab state, etc.) self.focus_split(split_id, buffer_id); diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index caf437faa..afbe4137f 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -2589,6 +2589,15 @@ impl Editor { /// /// Use this instead of calling set_active_split directly when switching focus. pub(super) fn focus_split(&mut self, split_id: LeafId, buffer_id: BufferId) { + // Fixed buffer-group panels (toolbars, headers, footers) aren't focus + // targets: focusing them would route keyboard input at an invisible + // cursor. Plugins can still detect clicks via the mouse_click hook, + // which fires in the click handlers before reaching here. Scrollable + // panels still receive focus even with a hidden cursor. + if self.is_non_scrollable_buffer(buffer_id) { + return; + } + let previous_split = self.split_manager.active_split(); let previous_buffer = self.active_buffer(); // Get BEFORE changing split let split_changed = previous_split != split_id; @@ -2702,15 +2711,17 @@ impl Editor { self.buffers.get_mut(&self.active_buffer()).unwrap() } - /// Get the cursors for the active buffer in the active split + /// Get the cursors for the active buffer in the active split. + /// Uses `effective_active_split` so focused buffer-group panels return + /// their own cursors (not the outer split's stale ones). pub fn active_cursors(&self) -> &Cursors { - let split_id = self.split_manager.active_split(); + let split_id = self.effective_active_split(); &self.split_view_states.get(&split_id).unwrap().cursors } /// Get the cursors for the active buffer in the active split (mutable) pub fn active_cursors_mut(&mut self) -> &mut Cursors { - let split_id = self.split_manager.active_split(); + let split_id = self.effective_active_split(); &mut self.split_view_states.get_mut(&split_id).unwrap().cursors } @@ -5464,13 +5475,32 @@ impl Editor { }; snapshot.buffer_saved_diffs.insert(*buffer_id, diff); - // Store cursor position for this buffer (from any split that has it) - let cursor_pos = self - .split_view_states - .values() - .find_map(|vs| vs.buffer_state(*buffer_id)) + // Regular buffers live in exactly one split's keyed_states. + // Panel (hidden) buffers natively live inside a group's inner + // split — but the close-buffer path can leave a *shadow* + // entry in the group's host split (from `switch_buffer`'s + // auto-insert, kept to preserve the + // `active_buffer ∈ keyed_states` invariant). For hidden + // buffers we therefore skip group-host splits and pick the + // inner split, which is the authoritative home. + let is_hidden = self + .buffer_metadata + .get(buffer_id) + .is_some_and(|m| m.hidden_from_tabs); + let source_split = self.split_view_states.iter().find(|(split_id, vs)| { + vs.keyed_states.contains_key(buffer_id) + && !(is_hidden && self.grouped_subtrees.contains_key(split_id)) + }); + let cursor_pos = source_split + .and_then(|(_, vs)| vs.buffer_state(*buffer_id)) .map(|bs| bs.cursors.primary().position) .unwrap_or(0); + tracing::trace!( + "snapshot: buffer {:?} cursor_pos={} (from split {:?})", + buffer_id, + cursor_pos, + source_split.map(|(id, _)| *id), + ); snapshot .buffer_cursor_positions .insert(*buffer_id, cursor_pos); @@ -6014,9 +6044,17 @@ impl Editor { bindings, read_only, allow_text_input, + inherit_normal_bindings, plugin_name, } => { - self.handle_define_mode(name, bindings, read_only, allow_text_input, plugin_name); + self.handle_define_mode( + name, + bindings, + read_only, + allow_text_input, + inherit_normal_bindings, + plugin_name, + ); } // ==================== File/Navigation Commands ==================== diff --git a/crates/fresh-editor/src/app/mouse_input.rs b/crates/fresh-editor/src/app/mouse_input.rs index 61ed84262..e7da8cc91 100644 --- a/crates/fresh-editor/src/app/mouse_input.rs +++ b/crates/fresh-editor/src/app/mouse_input.rs @@ -1105,6 +1105,13 @@ impl Editor { ) -> AnyhowResult<()> { use crate::model::event::Event; + // Fixed panels (toolbars, headers) are inert — no click focus, + // no selection. Scrollable group panels still accept clicks even + // when their cursor is hidden. + if self.is_non_scrollable_buffer(buffer_id) { + return Ok(()); + } + // Focus this split self.focus_split(split_id, buffer_id); @@ -1245,6 +1252,10 @@ impl Editor { ) -> AnyhowResult<()> { use crate::model::event::Event; + if self.is_non_scrollable_buffer(buffer_id) { + return Ok(()); + } + // Focus this split self.focus_split(split_id, buffer_id); diff --git a/crates/fresh-editor/src/app/plugin_commands.rs b/crates/fresh-editor/src/app/plugin_commands.rs index 2edd100aa..d7b65a3de 100644 --- a/crates/fresh-editor/src/app/plugin_commands.rs +++ b/crates/fresh-editor/src/app/plugin_commands.rs @@ -1537,6 +1537,7 @@ impl Editor { bindings: Vec<(String, String)>, read_only: bool, allow_text_input: bool, + inherit_normal_bindings: bool, plugin_name: Option, ) { use super::parse_key_string; @@ -1546,13 +1547,15 @@ impl Editor { let mode = BufferMode::new(name.clone()) .with_read_only(read_only) .with_allow_text_input(allow_text_input) + .with_inherit_normal_bindings(inherit_normal_bindings) .with_plugin_name(plugin_name); // Clear any existing plugin defaults for this mode before re-registering - self.keybindings - .write() - .unwrap() - .clear_plugin_defaults_for_mode(&name); + { + let mut kb = self.keybindings.write().unwrap(); + kb.clear_plugin_defaults_for_mode(&name); + kb.set_mode_inherits_normal_bindings(&name, inherit_normal_bindings); + } let mode_context = KeyContext::Mode(name.clone()); diff --git a/crates/fresh-editor/src/app/workspace.rs b/crates/fresh-editor/src/app/workspace.rs index a1acb7375..d225c5003 100644 --- a/crates/fresh-editor/src/app/workspace.rs +++ b/crates/fresh-editor/src/app/workspace.rs @@ -278,6 +278,22 @@ impl Editor { tracing::debug!("Captured {} external files", external_files.len()); } + // Capture read-only file paths. Store relative when inside + // working_dir (matches how open_tabs paths are stored), otherwise + // absolute — mirrors external_files. + let read_only_files: Vec = self + .buffer_metadata + .values() + .filter(|meta| meta.read_only) + .filter_map(|meta| meta.file_path().cloned()) + .filter(|p| !p.as_os_str().is_empty()) + .map(|p| { + p.strip_prefix(&self.working_dir) + .map(|rel| rel.to_path_buf()) + .unwrap_or(p) + }) + .collect(); + // Capture unnamed buffer references (for hot_exit) let unnamed_buffers: Vec = if self.config.editor.hot_exit { self.buffer_metadata @@ -325,6 +341,7 @@ impl Editor { bookmarks, terminals, external_files, + read_only_files, unnamed_buffers, plugin_global_state: self.plugin_global_state.clone(), saved_at: std::time::SystemTime::now() @@ -751,6 +768,19 @@ impl Editor { } } + // Re-apply read-only flag for files that were locked in the saved + // session. Paths in read_only_files are relative (under working_dir) + // or absolute — try both lookups. + for ro_path in &workspace.read_only_files { + let buffer_id = path_to_buffer + .get(ro_path) + .copied() + .or_else(|| path_to_buffer.get(&self.working_dir.join(ro_path)).copied()); + if let Some(id) = buffer_id { + self.mark_buffer_read_only(id, true); + } + } + // 5b2. Apply hot exit recovery: restore unsaved changes to file-backed buffers if self.config.editor.hot_exit { let entries = self.recovery_service.list_recoverable().unwrap_or_default(); diff --git a/crates/fresh-editor/src/input/buffer_mode.rs b/crates/fresh-editor/src/input/buffer_mode.rs index 2249fccec..f7cd74821 100644 --- a/crates/fresh-editor/src/input/buffer_mode.rs +++ b/crates/fresh-editor/src/input/buffer_mode.rs @@ -20,6 +20,11 @@ pub struct BufferMode { /// without registering individual bindings for every character. pub allow_text_input: bool, + /// When true, keys not bound by this mode fall through to the Normal-context + /// bindings (motion, selection, copy, …) instead of being dropped. Lets + /// viewer-style modes skip re-declaring every built-in cursor action. + pub inherit_normal_bindings: bool, + /// Name of the plugin that registered this mode (for attribution in keybinding editor) pub plugin_name: Option, } @@ -31,6 +36,7 @@ impl BufferMode { name: name.into(), read_only: false, allow_text_input: false, + inherit_normal_bindings: false, plugin_name: None, } } @@ -52,6 +58,12 @@ impl BufferMode { self.allow_text_input = allow; self } + + /// Set whether unbound keys fall through to Normal-context bindings + pub fn with_inherit_normal_bindings(mut self, inherit: bool) -> Self { + self.inherit_normal_bindings = inherit; + self + } } /// Registry for buffer modes — stores metadata only. @@ -97,6 +109,14 @@ impl ModeRegistry { .unwrap_or(false) } + /// Check if a mode inherits Normal-context bindings for unbound keys + pub fn inherits_normal_bindings(&self, mode_name: &str) -> bool { + self.modes + .get(mode_name) + .map(|m| m.inherit_normal_bindings) + .unwrap_or(false) + } + /// List all registered mode names pub fn list_modes(&self) -> Vec { self.modes.keys().cloned().collect() diff --git a/crates/fresh-editor/src/input/keybindings.rs b/crates/fresh-editor/src/input/keybindings.rs index 8b19aed4a..b7fa027ac 100644 --- a/crates/fresh-editor/src/input/keybindings.rs +++ b/crates/fresh-editor/src/input/keybindings.rs @@ -1243,6 +1243,11 @@ pub struct KeybindingResolver { /// Plugin default chord bindings (for mode chord bindings from defineMode) plugin_chord_defaults: HashMap, Action>>, + + /// Plugin modes that want unbound keys to fall through to Normal + /// bindings (motion, selection, copy). Populated by `defineMode` when + /// `inheritNormalBindings: true`. + inheriting_modes: std::collections::HashSet, } impl KeybindingResolver { @@ -1255,6 +1260,7 @@ impl KeybindingResolver { chord_bindings: HashMap::new(), default_chord_bindings: HashMap::new(), plugin_chord_defaults: HashMap::new(), + inheriting_modes: std::collections::HashSet::new(), }; // Load bindings from the active keymap (with inheritance resolution) into default_bindings @@ -1435,6 +1441,17 @@ impl KeybindingResolver { let context = KeyContext::Mode(mode_name.to_string()); self.plugin_defaults.remove(&context); self.plugin_chord_defaults.remove(&context); + self.inheriting_modes.remove(mode_name); + } + + /// Mark (or unmark) a plugin mode as inheriting Normal-context bindings + /// for keys it doesn't bind itself. + pub fn set_mode_inherits_normal_bindings(&mut self, mode_name: &str, inherit: bool) { + if inherit { + self.inheriting_modes.insert(mode_name.to_string()); + } else { + self.inheriting_modes.remove(mode_name); + } } /// Get all plugin default bindings (for keybinding editor display) @@ -1642,7 +1659,8 @@ impl KeybindingResolver { // Contexts with allows_normal_fallthrough (e.g. CompositeBuffer) get ALL // Normal bindings; other contexts only get application-wide actions. if context != KeyContext::Normal { - let full_fallthrough = context.allows_normal_fallthrough(); + let full_fallthrough = context.allows_normal_fallthrough() + || matches!(&context, KeyContext::Mode(name) if self.inheriting_modes.contains(name)); if let Some(normal_bindings) = self.bindings.get(&KeyContext::Normal) { if let Some(action) = normal_bindings.get(norm) { diff --git a/crates/fresh-editor/src/main.rs b/crates/fresh-editor/src/main.rs index fb7f9f0c7..9887c32ce 100644 --- a/crates/fresh-editor/src/main.rs +++ b/crates/fresh-editor/src/main.rs @@ -2130,7 +2130,10 @@ fn list_grammars_command() -> AnyhowResult<()> { let dir_context = fresh::config_io::DirectoryContext::from_system()?; let config_dir = dir_context.config_dir.clone(); let registry = fresh::primitives::grammar::GrammarRegistry::for_editor(config_dir); - let grammars = registry.available_grammar_info(); + // Load the user config so tree-sitter-only languages (e.g. TypeScript) + // still appear even though syntect has no grammar for them. + let config = fresh::config::Config::load_with_layers(&dir_context, &std::env::current_dir()?); + let grammars = registry.available_grammar_info_with_languages(&config.languages); if grammars.is_empty() { println!("No grammars available."); diff --git a/crates/fresh-editor/src/primitives/detected_language.rs b/crates/fresh-editor/src/primitives/detected_language.rs index be3874395..9b4a79922 100644 --- a/crates/fresh-editor/src/primitives/detected_language.rs +++ b/crates/fresh-editor/src/primitives/detected_language.rs @@ -141,21 +141,22 @@ impl DetectedLanguage { registry: &GrammarRegistry, languages: &HashMap, ) -> Option { - if registry.find_syntax_by_name(name).is_some() { - let ts_language = Language::from_name(name); - let highlighter = HighlightEngine::for_syntax_name(name, registry, ts_language); - // Resolve the canonical language ID from config (e.g., "Rust" → "rust"). - let language_id = - resolve_language_id(name, registry, languages).unwrap_or_else(|| name.to_string()); - Some(Self { - name: language_id, - display_name: name.to_string(), - highlighter, - ts_language, - }) - } else { - None + let ts_language = Language::from_name(name); + // Accept the selection if EITHER syntect has a grammar by this name + // OR tree-sitter does. Bailing on syntect-only lookup skipped + // tree-sitter-only languages like TypeScript. + if registry.find_syntax_by_name(name).is_none() && ts_language.is_none() { + return None; } + let highlighter = HighlightEngine::for_syntax_name(name, registry, ts_language); + let language_id = + resolve_language_id(name, registry, languages).unwrap_or_else(|| name.to_string()); + Some(Self { + name: language_id, + display_name: name.to_string(), + highlighter, + ts_language, + }) } /// Create a DetectedLanguage for a user-configured language that has no diff --git a/crates/fresh-editor/src/primitives/grammar/types.rs b/crates/fresh-editor/src/primitives/grammar/types.rs index 61c1f2a90..9cca7552c 100644 --- a/crates/fresh-editor/src/primitives/grammar/types.rs +++ b/crates/fresh-editor/src/primitives/grammar/types.rs @@ -1016,6 +1016,48 @@ impl GrammarRegistry { .collect() } + /// Like `available_grammar_info` but also merges in languages that only + /// have a tree-sitter grammar (no syntect definition). Those languages + /// are selectable from the language palette and can highlight a buffer, + /// but wouldn't show up if we only listed syntect syntaxes — e.g. + /// TypeScript is tree-sitter-only. + pub fn available_grammar_info_with_languages( + &self, + languages: &HashMap, + ) -> Vec { + let mut result = self.available_grammar_info(); + let existing: std::collections::HashSet = + result.iter().map(|g| g.name.to_lowercase()).collect(); + + for (lang_id, lang_cfg) in languages { + let grammar = if lang_cfg.grammar.is_empty() { + lang_id.as_str() + } else { + lang_cfg.grammar.as_str() + }; + // Resolve to a tree-sitter language; skip if neither syntect nor + // tree-sitter knows this grammar (there's nothing to highlight). + let Some(ts_lang) = fresh_languages::Language::from_name(grammar) + .or_else(|| fresh_languages::Language::from_id(lang_id)) + else { + continue; + }; + let display_name = ts_lang.display_name().to_string(); + if existing.contains(&display_name.to_lowercase()) { + continue; + } + result.push(GrammarInfo { + name: display_name, + source: GrammarSource::BuiltIn, + file_extensions: lang_cfg.extensions.clone(), + short_name: None, + }); + } + + result.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + result + } + /// List all available grammars with provenance information. /// /// Returns a sorted list of `GrammarInfo` entries. Each entry includes diff --git a/crates/fresh-editor/src/primitives/highlight_engine.rs b/crates/fresh-editor/src/primitives/highlight_engine.rs index fde388a42..856326589 100644 --- a/crates/fresh-editor/src/primitives/highlight_engine.rs +++ b/crates/fresh-editor/src/primitives/highlight_engine.rs @@ -1104,6 +1104,15 @@ impl HighlightEngine { } } + // No TextMate grammar — fall back to tree-sitter the same way + // `for_file` does, so "set language TypeScript" on a non-.ts buffer + // still gets highlighted (syntect ships no TypeScript grammar). + if let Some(lang) = ts_language { + if let Ok(highlighter) = Highlighter::new(lang) { + return Self::TreeSitter(Box::new(highlighter)); + } + } + Self::None } diff --git a/crates/fresh-editor/src/state.rs b/crates/fresh-editor/src/state.rs index 54cf8da35..2763d06df 100644 --- a/crates/fresh-editor/src/state.rs +++ b/crates/fresh-editor/src/state.rs @@ -185,6 +185,11 @@ pub struct EditorState { /// but navigation, selection, and copy are still allowed pub editing_disabled: bool, + /// Whether this buffer can be scrolled (default true). Fixed buffer-group + /// panels (toolbars, headers, footers) set this to false so the mouse + /// wheel is ignored and no scrollbar is drawn. + pub scrollable: bool, + /// Per-buffer user settings (tab size, indentation style, etc.) /// These settings are preserved across file reloads (auto-revert) pub buffer_settings: BufferSettings, @@ -287,6 +292,7 @@ impl EditorState { text_properties: TextPropertyManager::new(), show_cursors: true, editing_disabled: false, + scrollable: true, buffer_settings: BufferSettings::default(), reference_highlighter: ReferenceHighlighter::new(), is_composite_buffer: false, diff --git a/crates/fresh-editor/src/view/overlay.rs b/crates/fresh-editor/src/view/overlay.rs index a7a7ea88e..2ee7cb84e 100644 --- a/crates/fresh-editor/src/view/overlay.rs +++ b/crates/fresh-editor/src/view/overlay.rs @@ -288,6 +288,17 @@ impl OverlayManager { handle } + /// Append many overlays at once, sorting a single time at the end. + /// + /// `add` re-sorts the whole vector on every insertion, which is O(n² log n) + /// when a caller has N overlays to add. Use this instead when rebuilding an + /// overlay set from scratch (e.g. `set_virtual_buffer_content`), where the + /// caller already owns the full list up front. + pub fn extend>(&mut self, overlays: I) { + self.overlays.extend(overlays); + self.overlays.sort_by_key(|o| o.priority); + } + /// Remove an overlay by its handle pub fn remove_by_handle( &mut self, diff --git a/crates/fresh-editor/src/view/ui/split_rendering.rs b/crates/fresh-editor/src/view/ui/split_rendering.rs index c37e7c3a3..e46633999 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering.rs @@ -1113,6 +1113,12 @@ impl SplitRenderer { .and_then(|svs| svs.get(&split_id)) .is_some_and(|vs| vs.hide_tilde); + // Non-scrollable panels (Fixed toolbars/headers/footers by default, + // or any panel created with `scrollable: false`) don't get a + // scrollbar — their content is pinned to the panel size. + let is_non_scrollable = buffers.get(&buffer_id).is_some_and(|s| !s.scrollable); + let panel_show_vscroll = show_vertical_scrollbar && !is_non_scrollable; + let layout = if is_inner_group_leaf { // Inner leaf: split_area IS the content rect already. SplitLayout { @@ -1120,17 +1126,15 @@ impl SplitRenderer { content_rect: Rect::new( split_area.x, split_area.y, - split_area.width.saturating_sub(if show_vertical_scrollbar { - 1 - } else { - 0 - }), + split_area + .width + .saturating_sub(if panel_show_vscroll { 1 } else { 0 }), split_area.height, ), scrollbar_rect: Rect::new( split_area.x + split_area.width.saturating_sub(1), split_area.y, - if show_vertical_scrollbar { 1 } else { 0 }, + if panel_show_vscroll { 1 } else { 0 }, split_area.height, ), horizontal_scrollbar_rect: Rect::new(0, 0, 0, 0), @@ -1139,8 +1143,8 @@ impl SplitRenderer { Self::split_layout( split_area, split_tab_bar_visible, - show_vertical_scrollbar, - show_horizontal_scrollbar, + show_vertical_scrollbar && !is_non_scrollable, + show_horizontal_scrollbar && !is_non_scrollable, ) }; let (split_buffers, tab_scroll_offset) = if is_inner_group_leaf { @@ -1334,18 +1338,19 @@ impl SplitRenderer { // Render scrollbar for composite buffer let total_rows = composite.row_count(); let content_height = layout.content_rect.height.saturating_sub(1) as usize; // -1 for header - let (thumb_start, thumb_end) = if show_vertical_scrollbar { - Self::render_composite_scrollbar( - frame, - layout.scrollbar_rect, - total_rows, - view_state.scroll_row, - content_height, - is_active, - ) - } else { - (0, 0) - }; + let (thumb_start, thumb_end) = + if show_vertical_scrollbar && !is_non_scrollable { + Self::render_composite_scrollbar( + frame, + layout.scrollbar_rect, + total_rows, + view_state.scroll_row, + content_height, + is_active, + ) + } else { + (0, 0) + }; // Store the areas for mouse handling split_areas.push(( @@ -1487,7 +1492,7 @@ impl SplitRenderer { }; // Render vertical scrollbar for this split and get thumb position - let (thumb_start, thumb_end) = if show_vertical_scrollbar { + let (thumb_start, thumb_end) = if show_vertical_scrollbar && !is_non_scrollable { Self::render_scrollbar( frame, state, diff --git a/crates/fresh-editor/src/view/ui/tabs.rs b/crates/fresh-editor/src/view/ui/tabs.rs index 858d6a964..27069a149 100644 --- a/crates/fresh-editor/src/view/ui/tabs.rs +++ b/crates/fresh-editor/src/view/ui/tabs.rs @@ -469,7 +469,13 @@ impl TabsRenderer { _ => (false, false), }; - // Determine base style + // Determine base style. For the inactive split's active tab, + // we keep BOLD to show which tab is active inside that split, + // but use `tab_inactive_fg` instead of `tab_active_fg`. Pairing + // `tab_active_fg` with `tab_inactive_bg` assumed active_fg was + // chosen against active_bg — which breaks on themes (e.g. + // high-contrast) where active_fg == inactive_bg and the tab + // label disappears. let mut base_style = if is_active { if is_active_split { Style::default() @@ -478,7 +484,7 @@ impl TabsRenderer { .add_modifier(Modifier::BOLD) } else { Style::default() - .fg(theme.tab_active_fg) + .fg(theme.tab_inactive_fg) .bg(theme.tab_inactive_bg) .add_modifier(Modifier::BOLD) } diff --git a/crates/fresh-editor/src/workspace.rs b/crates/fresh-editor/src/workspace.rs index 759d8b153..f219dc1b3 100644 --- a/crates/fresh-editor/src/workspace.rs +++ b/crates/fresh-editor/src/workspace.rs @@ -83,6 +83,11 @@ pub struct Workspace { #[serde(default)] pub external_files: Vec, + /// Files that were read-only at save time; re-applied on restore. + /// Relative to `working_dir` when possible, otherwise absolute. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub read_only_files: Vec, + /// Unnamed buffers that should be restored from recovery files #[serde(default, skip_serializing_if = "Vec::is_empty")] pub unnamed_buffers: Vec, @@ -878,6 +883,7 @@ impl Workspace { bookmarks: HashMap::new(), terminals: Vec::new(), external_files: Vec::new(), + read_only_files: Vec::new(), unnamed_buffers: Vec::new(), plugin_global_state: HashMap::new(), saved_at: SystemTime::now() diff --git a/crates/fresh-editor/tests/e2e/blog_showcases.rs b/crates/fresh-editor/tests/e2e/blog_showcases.rs index fa46e45d6..6a4bcb72e 100644 --- a/crates/fresh-editor/tests/e2e/blog_showcases.rs +++ b/crates/fresh-editor/tests/e2e/blog_showcases.rs @@ -12,6 +12,7 @@ use crate::common::blog_showcase::BlogShowcase; use crate::common::fixtures::TestFixture; +use crate::common::git_test_helper::GitTestRepo; use crate::common::harness::{copy_plugin, copy_plugin_lib, EditorTestHarness, HarnessOptions}; use crossterm::event::{KeyCode, KeyModifiers}; use lsp_types::FoldingRange; @@ -2261,3 +2262,208 @@ fn blog_showcase_fresh_0_2_18_hot_exit() { s.finalize().unwrap(); } + +// ========================================================================= +// Blog Post 6: Modernized Git Log Panel (buffer group + live preview) +// ========================================================================= + +/// Build a hermetic repo with several commits by distinct authors so the +/// aligned-column log and right-panel detail have something meaningful +/// to display in the showcase. +fn build_git_log_demo_repo() -> GitTestRepo { + let repo = GitTestRepo::new(); + + // Commit 1 — initial scaffold (Alice). + repo.create_file( + "src/main.rs", + "fn main() {\n println!(\"Hello, world!\");\n}\n", + ); + repo.create_file("README.md", "# Fresh demo\n\nA tiny sample project.\n"); + repo.git_add_all(); + repo.git_commit("feat: initial scaffold"); + + // Commit 2 — add add() (Alice). + repo.create_file( + "src/lib.rs", + "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n", + ); + repo.git_add_all(); + repo.git_commit("feat: add add() helper"); + + // Commit 3 — add sub() from a different author (John Doe). + std::process::Command::new("git") + .args(["config", "user.name", "John Doe"]) + .current_dir(&repo.path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "john@example.com"]) + .current_dir(&repo.path) + .output() + .unwrap(); + repo.create_file( + "src/lib.rs", + "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n\n\ + pub fn sub(a: i32, b: i32) -> i32 {\n a - b\n}\n", + ); + repo.git_add_all(); + repo.git_commit("feat: add sub() helper"); + + // Commit 4 — docs (Alice again). + std::process::Command::new("git") + .args(["config", "user.name", "Alice Liddell"]) + .current_dir(&repo.path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "alice@example.com"]) + .current_dir(&repo.path) + .output() + .unwrap(); + repo.create_file( + "README.md", + "# Fresh demo\n\nA tiny sample project.\n\n\ + Provides basic arithmetic helpers.\n", + ); + repo.git_add_all(); + repo.git_commit("docs: describe the helpers"); + + // Commit 5 — cli args TODO (Alice). Tag v0.1 on this one so the log + // panel shows a tag ref as well as the HEAD branch ref. + repo.create_file( + "src/main.rs", + "fn main() {\n println!(\"Hello, world!\");\n}\n\ + // TODO: support CLI args\n", + ); + repo.git_add_all(); + repo.git_commit("chore(main): note CLI args TODO"); + std::process::Command::new("git") + .args(["tag", "v0.1.0"]) + .current_dir(&repo.path) + .output() + .unwrap(); + + // Commit 6 — CLI parser (John again). + std::process::Command::new("git") + .args(["config", "user.name", "John Doe"]) + .current_dir(&repo.path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "john@example.com"]) + .current_dir(&repo.path) + .output() + .unwrap(); + repo.create_file( + "src/args.rs", + "pub fn parse_args(args: &[String]) -> Vec {\n\ + \u{0020} args.iter().skip(1).cloned().collect()\n}\n", + ); + repo.git_add_all(); + repo.git_commit("feat(cli): add args parser"); + + repo +} + +/// Git Log: the modern buffer-group layout with a live-preview right panel. +/// Walk through the commit list with j/k and show the right-hand detail +/// update in step, then focus the detail panel with Tab and close with q. +#[test] +#[ignore] +fn blog_showcase_productivity_git_log() { + let repo = build_git_log_demo_repo(); + repo.setup_git_log_plugin(); + + // 140x34 is comfortable — the log panel's aligned columns + the detail + // panel's diff read well side-by-side at that width. + let mut h = EditorTestHarness::with_config_and_working_dir( + 140, + 34, + Default::default(), + repo.path.clone(), + ) + .unwrap(); + h.render().unwrap(); + + let mut s = BlogShowcase::new( + "productivity/git-log", + "Git Log", + "Magit-style git log with live-preview: a buffer-group tab pairs the aligned commit list with the selected commit's detail, updated as you navigate.", + ); + + // Opening hold on the blank editor — a moment to read the key badge. + hold(&mut h, &mut s, 5, 120); + + // Open the command palette and type "Git Log". + h.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL) + .unwrap(); + h.render().unwrap(); + snap(&mut h, &mut s, Some("Ctrl+P"), 150); + + for ch in "Git Log".chars() { + h.send_key(KeyCode::Char(ch), KeyModifiers::NONE).unwrap(); + h.render().unwrap(); + snap(&mut h, &mut s, Some(&ch.to_string()), 60); + } + hold(&mut h, &mut s, 2, 120); + + // Confirm — this runs `show_git_log` which creates the buffer group. + h.send_key(KeyCode::Enter, KeyModifiers::NONE).unwrap(); + // The group buffer + `git show` dispatch is async; wait for the log + // header *and* the detail-panel "Author:" line so both panels are + // fully rendered before we start snapping. + h.wait_until(|h| { + let screen = h.screen_to_string(); + screen.contains("Commits:") && screen.contains("Author:") + }) + .unwrap(); + snap(&mut h, &mut s, Some("Enter"), 300); + hold(&mut h, &mut s, 5, 120); + + // Walk down the commit list — each `j` fires `cursor_moved`, which + // re-renders the right panel with the newly-selected commit's diff. + for _ in 0..3 { + h.send_key(KeyCode::Char('j'), KeyModifiers::NONE).unwrap(); + // Let the async `git show` land before we snapshot so the right + // panel matches the highlighted row. + h.process_async_and_render().unwrap(); + snap(&mut h, &mut s, Some("j"), 180); + hold(&mut h, &mut s, 2, 120); + } + // One more for effect. + h.send_key(KeyCode::Char('j'), KeyModifiers::NONE).unwrap(); + h.process_async_and_render().unwrap(); + snap(&mut h, &mut s, Some("j"), 220); + hold(&mut h, &mut s, 3, 150); + + // Climb back up to highlight live-update going both directions. + for _ in 0..2 { + h.send_key(KeyCode::Char('k'), KeyModifiers::NONE).unwrap(); + h.process_async_and_render().unwrap(); + snap(&mut h, &mut s, Some("k"), 180); + } + hold(&mut h, &mut s, 3, 120); + + // Tab jumps focus into the detail panel so the user can scroll the + // diff without losing the log's cursor. + h.send_key(KeyCode::Tab, KeyModifiers::NONE).unwrap(); + h.render().unwrap(); + snap(&mut h, &mut s, Some("Tab"), 200); + hold(&mut h, &mut s, 4, 150); + + // q in the detail panel hops back to the log panel (it doesn't close + // the group until q is pressed from the log panel). + h.send_key(KeyCode::Char('q'), KeyModifiers::NONE).unwrap(); + h.render().unwrap(); + snap(&mut h, &mut s, Some("q"), 180); + hold(&mut h, &mut s, 3, 120); + + // Final close — q in the log panel tears the group down. + h.send_key(KeyCode::Char('q'), KeyModifiers::NONE).unwrap(); + h.wait_until(|h| !h.screen_to_string().contains("Commits:")) + .unwrap(); + snap(&mut h, &mut s, Some("q"), 220); + hold(&mut h, &mut s, 4, 150); + + s.finalize().unwrap(); +} diff --git a/crates/fresh-editor/tests/e2e/plugins/git.rs b/crates/fresh-editor/tests/e2e/plugins/git.rs index d87ae0b1d..f27b6a95e 100644 --- a/crates/fresh-editor/tests/e2e/plugins/git.rs +++ b/crates/fresh-editor/tests/e2e/plugins/git.rs @@ -975,19 +975,21 @@ fn test_git_log_shows_commits() { // Trigger git log trigger_git_log(&mut harness); - // Wait for git log to load + // Wait for git log to load (sticky toolbar + at least one commit subject) harness .wait_until(|h| { let screen = h.screen_to_string(); - // Should show "Commits:" header and at least one commit hash - screen.contains("Commits:") && screen.contains("Initial commit") + screen.contains("switch pane") && screen.contains("Initial commit") }) .unwrap(); let screen = harness.screen_to_string(); println!("Git log screen:\n{screen}"); - assert!(screen.contains("Commits:"), "Should show Commits: header"); + assert!( + screen.contains("Initial commit"), + "Should show the seeded commit subject" + ); } /// Test git log cursor navigation @@ -1027,7 +1029,7 @@ fn test_git_log_cursor_navigation() { // Wait for git log to load harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); // Navigate down using j key (should work via inherited normal mode) @@ -1050,7 +1052,7 @@ fn test_git_log_cursor_navigation() { println!("After navigation:\n{screen}"); // Git log should still be visible - assert!(screen.contains("Commits:")); + assert!(screen.contains("switch pane")); } /// Test git log show commit detail with Enter @@ -1077,7 +1079,7 @@ fn test_git_log_show_commit_detail() { // Wait for git log to load harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); // Move cursor to a commit line (down from header) @@ -1102,14 +1104,15 @@ fn test_git_log_show_commit_detail() { println!("Commit detail screen:\n{screen}"); } -/// Test going back from commit detail to git log +/// Pressing `q` while the detail panel has focus closes the whole git-log +/// group. The older behaviour stepped focus back to the log panel first, +/// making close a two-keystroke gesture that surprised users. #[test] -fn test_git_log_back_from_commit_detail() { +fn test_git_log_q_from_detail_closes_group() { let repo = GitTestRepo::new(); repo.setup_typical_project(); repo.setup_git_log_plugin(); - // Change to repo directory so git commands work correctly let original_dir = repo.change_to_repo_dir(); let _guard = DirGuard::new(original_dir); @@ -1121,42 +1124,25 @@ fn test_git_log_back_from_commit_detail() { ) .unwrap(); - // Trigger git log trigger_git_log(&mut harness); - // Wait for git log to load - harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) - .unwrap(); - - // Move to commit and show detail - harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); - harness.process_async_and_render().unwrap(); - harness - .send_key(KeyCode::Enter, KeyModifiers::NONE) - .unwrap(); - - // Wait for commit detail + // Wait for the detail panel to populate (live-preview of HEAD). harness .wait_until(|h| h.screen_to_string().contains("Author:")) .unwrap(); - let screen_detail = harness.screen_to_string(); - println!("Commit detail:\n{screen_detail}"); + // Move focus into the detail panel. + harness.send_key(KeyCode::Tab, KeyModifiers::NONE).unwrap(); + harness.process_async_and_render().unwrap(); - // Press q to go back to git log + // q from the detail panel should close the entire group: the toolbar + // (and its "switch pane" hint) disappears along with the *Git Log* tab. harness .send_key(KeyCode::Char('q'), KeyModifiers::NONE) .unwrap(); - harness.process_async_and_render().unwrap(); - - // Wait for git log to reappear harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| !h.screen_to_string().contains("switch pane")) .unwrap(); - - let screen_log = harness.screen_to_string(); - println!("Back to git log:\n{screen_log}"); } /// Test closing git log with q @@ -1183,11 +1169,11 @@ fn test_git_log_close() { // Wait for git log to load harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); let screen_before = harness.screen_to_string(); - assert!(screen_before.contains("Commits:")); + assert!(screen_before.contains("switch pane")); // Press q to close git log harness @@ -1203,7 +1189,8 @@ fn test_git_log_close() { println!("After closing:\n{screen_after}"); // Should no longer show git log - harness.assert_screen_not_contains("Commits:"); + // Toolbar is gone once the plugin's buffer group is closed. + harness.assert_screen_not_contains("switch pane"); } /// Test diff coloring in commit detail @@ -1231,7 +1218,7 @@ fn test_git_log_diff_coloring() { // Wait for git log to load harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); // Move to the commit and show detail @@ -1297,95 +1284,198 @@ fn test_git_log_open_different_commits_sequentially() { // Trigger git log trigger_git_log(&mut harness); - // Wait for git log to load + // The toolbar renders before `git log` finishes; wait for the actual + // commit rows in the log panel before asserting on them. harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| { + let s = h.screen_to_string(); + s.contains("THIRD_UNIQUE_COMMIT_CCC") + && s.contains("SECOND_UNIQUE_COMMIT_BBB") + && s.contains("FIRST_UNIQUE_COMMIT_AAA") + }) .unwrap(); let screen_log = harness.screen_to_string(); println!("Git log with commits:\n{screen_log}"); - // Verify all commits are visible - assert!( - screen_log.contains("THIRD_UNIQUE_COMMIT_CCC"), - "Should show third commit" - ); + // Initial selection is HEAD (THIRD) — detail panel auto-previews its diff. + harness + .wait_until(|h| h.screen_to_string().contains("file3.txt")) + .unwrap(); + + // Down → SECOND selected → detail switches to file2.txt. + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + harness + .wait_until(|h| h.screen_to_string().contains("file2.txt")) + .unwrap(); + let screen_second = harness.screen_to_string(); assert!( - screen_log.contains("SECOND_UNIQUE_COMMIT_BBB"), - "Should show second commit" + screen_second.contains("SECOND_UNIQUE_COMMIT_BBB"), + "Detail should reference SECOND commit subject:\n{screen_second}" ); + + // Down again → FIRST selected → detail switches to file1.txt. + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + harness + .wait_until(|h| h.screen_to_string().contains("file1.txt")) + .unwrap(); + let screen_first = harness.screen_to_string(); assert!( - screen_log.contains("FIRST_UNIQUE_COMMIT_AAA"), - "Should show first commit" + screen_first.contains("FIRST_UNIQUE_COMMIT_AAA"), + "Detail should reference FIRST commit subject:\n{screen_first}" ); +} - // Navigate down to the first commit line (header is line 1, commits start at line 2) - // Most recent commit (THIRD) is at the top - harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); - harness.process_async_and_render().unwrap(); +/// Pressing Down repeatedly in the log panel should progressively deepen the +/// selection: each press advances to the next-older commit, and the right-hand +/// detail panel updates to show that commit's diff. Regression test for a bug +/// where the log cursor jumped back to the top of the buffer once the detail +/// panel re-rendered, causing subsequent Down presses to stick on commit #2. +#[test] +fn test_git_log_down_arrow_progresses_through_commits() { + init_tracing_from_env(); + let repo = GitTestRepo::new(); + + // Four commits, each introduces a distinctively-named file so we can + // identify which commit's diff the detail panel is currently rendering. + repo.create_file("f1_alpha.txt", "one"); + repo.git_add(&["f1_alpha.txt"]); + repo.git_commit("Alpha commit"); + + repo.create_file("f2_beta.txt", "two"); + repo.git_add(&["f2_beta.txt"]); + repo.git_commit("Beta commit"); + + repo.create_file("f3_gamma.txt", "three"); + repo.git_add(&["f3_gamma.txt"]); + repo.git_commit("Gamma commit"); + + repo.create_file("f4_delta.txt", "four"); + repo.git_add(&["f4_delta.txt"]); + repo.git_commit("Delta commit"); + + repo.setup_git_log_plugin(); + + let mut harness = EditorTestHarness::with_config_and_working_dir( + 180, + 48, + Config::default(), + repo.path.clone(), + ) + .unwrap(); - // Open the first commit (THIRD_UNIQUE_COMMIT_CCC - most recent) + trigger_git_log(&mut harness); + + // After open, HEAD (Delta) should be auto-selected; detail panel shows its diff. harness - .send_key(KeyCode::Enter, KeyModifiers::NONE) + .wait_until(|h| h.screen_to_string().contains("f4_delta.txt")) .unwrap(); - // Wait for commit detail + // Down once — detail should switch to Gamma's diff (f3_gamma.txt). + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); harness - .wait_until(|h| { - let screen = h.screen_to_string(); - screen.contains("Author:") && screen.contains("THIRD_UNIQUE_COMMIT_CCC") - }) + .wait_until(|h| h.screen_to_string().contains("f3_gamma.txt")) .unwrap(); - let screen_first_detail = harness.screen_to_string(); - println!("First commit detail (should be THIRD):\n{screen_first_detail}"); - - // Press q to go back to git log + // Down again — if the log cursor jumps back to row 0 after the Gamma + // detail render, the next Down would only re-select Gamma and we'd + // never reach Beta. Assert that Beta's file shows up in the detail. + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); harness - .send_key(KeyCode::Char('q'), KeyModifiers::NONE) + .wait_until(|h| h.screen_to_string().contains("f2_beta.txt")) .unwrap(); - harness.process_async_and_render().unwrap(); - // Wait for git log to reappear + // Down once more for good measure — should reach Alpha. + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("f1_alpha.txt")) .unwrap(); +} + +/// Regression: pressing Enter on a diff line in the commit details panel +/// opens the file at that commit. Closing the file-view and pressing Enter +/// again on a diff line must also work — it previously failed with +/// "Move cursor to a diff line with file context" because the panel +/// buffer's cursor position was read from a stale mirror entry in the +/// outer split's keyed_states. +#[test] +fn test_git_log_open_file_works_after_closing_previous_file_view() { + init_tracing_from_env(); + let repo = GitTestRepo::new(); - let screen_back_to_log = harness.screen_to_string(); - println!("Back to git log:\n{screen_back_to_log}"); + repo.create_file("src/main.rs", "fn main() {\n println!(\"first\");\n}\n"); + repo.git_add(&["src/main.rs"]); + repo.git_commit("first commit"); - // Now navigate DOWN to a DIFFERENT commit (SECOND_UNIQUE_COMMIT_BBB) - harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); - harness.process_async_and_render().unwrap(); + // Same file edited twice so each commit has a diff body to land on. + repo.create_file("src/main.rs", "fn main() {\n println!(\"second\");\n}\n"); + repo.git_add(&["src/main.rs"]); + repo.git_commit("second commit"); + + repo.setup_git_log_plugin(); - let screen_after_nav = harness.screen_to_string(); - println!("After navigating down:\n{screen_after_nav}"); + let mut harness = EditorTestHarness::with_config_and_working_dir( + 180, + 48, + Config::default(), + repo.path.clone(), + ) + .unwrap(); - // Open the second commit - THIS IS THE BUG: it should open SECOND, not THIRD + trigger_git_log(&mut harness); + // Wait for the detail panel to render the second (HEAD) commit diff. harness - .send_key(KeyCode::Enter, KeyModifiers::NONE) + .wait_until(|h| h.screen_to_string().contains("+ println!(\"second\");")) .unwrap(); - // Wait for commit detail + // Focus the detail panel (Tab from log) and land on a diff line. + harness.send_key(KeyCode::Tab, KeyModifiers::NONE).unwrap(); + // Move down enough to be on an actual diff line (past the commit header). + for _ in 0..10 { + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + } + // First Enter: open file-view of src/main.rs @ HEAD. + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); harness .wait_until(|h| { - let screen = h.screen_to_string(); - screen.contains("Author:") + let s = h.screen_to_string(); + s.contains("println!(\"second\");") && !s.contains("Move cursor to a diff line") }) .unwrap(); - let screen_second_detail = harness.screen_to_string(); - println!("Second commit detail (should be SECOND):\n{screen_second_detail}"); + // Close the file-view (q) and go back to the detail panel. + harness + .send_key(KeyCode::Char('q'), KeyModifiers::NONE) + .unwrap(); + harness + .wait_until(|h| h.screen_to_string().contains("+ println!(\"second\");")) + .unwrap(); - // CRITICAL ASSERTION: The bug is that it opens the first commit again instead of the second - // This should show SECOND_UNIQUE_COMMIT_BBB, NOT THIRD_UNIQUE_COMMIT_CCC - assert!( - screen_second_detail.contains("SECOND_UNIQUE_COMMIT_BBB"), - "BUG: After navigating to a different commit and pressing Enter, it should open SECOND_UNIQUE_COMMIT_BBB, but got:\n{screen_second_detail}" - ); + // Nudge the detail cursor and press Enter again. Before the fix, + // the second Enter reported "Move cursor to a diff line with file context". + for _ in 0..5 { + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + } + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); + + // Success: either the file view opens (contains the source line) or + // at worst we get a benign "move cursor" message only if we actually + // landed outside a diff — but we're guaranteed a diff row by the + // Down presses above, so the failure mode is what the assert catches. + harness + .wait_until_stable(|h| { + let s = h.screen_to_string(); + s.contains("println!(\"second\");") || s.contains("println!(\"first\");") + }) + .unwrap(); + let s = harness.screen_to_string(); assert!( - !screen_second_detail.contains("THIRD_UNIQUE_COMMIT_CCC"), - "BUG: Should NOT show THIRD commit when SECOND was selected:\n{screen_second_detail}" + !s.contains("Move cursor to a diff line with file context"), + "BUG: second Enter fell back to move-cursor status:\n{s}", ); } diff --git a/crates/fresh-editor/tests/fixtures/large.rs b/crates/fresh-editor/tests/fixtures/large.rs index 49b43dc70..9780c520a 100644 --- a/crates/fresh-editor/tests/fixtures/large.rs +++ b/crates/fresh-editor/tests/fixtures/large.rs @@ -5840,9 +5840,17 @@ impl Editor { bindings, read_only, allow_text_input, + inherit_normal_bindings, plugin_name, } => { - self.handle_define_mode(name, bindings, read_only, allow_text_input, plugin_name); + self.handle_define_mode( + name, + bindings, + read_only, + allow_text_input, + inherit_normal_bindings, + plugin_name, + ); } // ==================== File/Navigation Commands ==================== diff --git a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs index a945a9764..9974ab09b 100644 --- a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs +++ b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs @@ -287,33 +287,54 @@ fn get_text_properties_at_cursor_typed( Err(_) => return TextPropertiesAtCursor(Vec::new()), }; let buffer_id_typed = BufferId(buffer_id as usize); - let cursor_pos = match snap - .buffer_cursor_positions - .get(&buffer_id_typed) - .copied() - .or_else(|| { - if snap.active_buffer_id == buffer_id_typed { - snap.primary_cursor.as_ref().map(|c| c.position) - } else { - None - } - }) { + let snapshot_pos = snap.buffer_cursor_positions.get(&buffer_id_typed).copied(); + let fallback_pos = if snap.active_buffer_id == buffer_id_typed { + snap.primary_cursor.as_ref().map(|c| c.position) + } else { + None + }; + let cursor_pos = match snapshot_pos.or(fallback_pos) { Some(pos) => pos, - None => return TextPropertiesAtCursor(Vec::new()), + None => { + tracing::debug!( + "getTextPropertiesAtCursor({:?}): no cursor (snapshot_pos={:?}, active_buffer={:?})", + buffer_id_typed, + snapshot_pos, + snap.active_buffer_id + ); + return TextPropertiesAtCursor(Vec::new()); + } }; let properties = match snap.buffer_text_properties.get(&buffer_id_typed) { Some(p) => p, - None => return TextPropertiesAtCursor(Vec::new()), + None => { + tracing::debug!( + "getTextPropertiesAtCursor({:?}): no text_properties in snapshot (cursor_pos={})", + buffer_id_typed, + cursor_pos + ); + return TextPropertiesAtCursor(Vec::new()); + } }; - // Find all properties at cursor position let result: Vec<_> = properties .iter() .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end) .map(|prop| prop.properties.clone()) .collect(); + tracing::debug!( + "getTextPropertiesAtCursor({:?}): cursor_pos={} (snapshot_pos={:?}, fallback_pos={:?}, active_buffer={:?}), total_props={}, matched={}", + buffer_id_typed, + cursor_pos, + snapshot_pos, + fallback_pos, + snap.active_buffer_id, + properties.len(), + result.len() + ); + TextPropertiesAtCursor(result) } @@ -2719,6 +2740,7 @@ impl JsEditorApi { bindings_arr: Vec>, read_only: rquickjs::function::Opt, allow_text_input: rquickjs::function::Opt, + inherit_normal_bindings: rquickjs::function::Opt, ) -> bool { let bindings: Vec<(String, String)> = bindings_arr .into_iter() @@ -2766,6 +2788,7 @@ impl JsEditorApi { bindings, read_only: read_only.0.unwrap_or(false), allow_text_input: allow_text, + inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false), plugin_name: Some(self.plugin_name.clone()), }) .is_ok() @@ -5612,6 +5635,7 @@ mod tests { bindings, read_only, allow_text_input, + inherit_normal_bindings, plugin_name, } => { assert_eq!(name, "test-mode"); @@ -5620,6 +5644,7 @@ mod tests { assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string())); assert!(!read_only); assert!(!allow_text_input); + assert!(!inherit_normal_bindings); assert!(plugin_name.is_some()); } _ => panic!("Expected DefineMode, got {:?}", cmd),