@@ -12,7 +12,7 @@ import {
1212} from "@tauri-apps/plugin-notification" ;
1313import { getCurrentWebview } from "@tauri-apps/api/webview" ;
1414import { getCurrentWindow } from "@tauri-apps/api/window" ;
15- import { openUrl } from "@tauri-apps/plugin-opener" ;
15+ import { openUrl , revealItemInDir } from "@tauri-apps/plugin-opener" ;
1616import { marked } from "marked" ;
1717import DOMPurify from "dompurify" ;
1818import hljs from "highlight.js" ;
@@ -388,6 +388,15 @@ const I18N: Record<Lang, Record<string, string>> = {
388388 filePreviewCopied : "Copiado al portapapeles" ,
389389 filePreviewSaved : "Guardado" ,
390390 filePreviewSaveError : "No se pudo guardar el archivo" ,
391+ ctxPaste : "Pegar" ,
392+ ctxSelectAll : "Seleccionar todo" ,
393+ ctxFind : "Buscar…" ,
394+ ctxClear : "Limpiar pantalla" ,
395+ ctxScrollBottom : "Ir al final" ,
396+ ctxCopyOutput : "Copiar salida del comando" ,
397+ ctxStopProcess : "Detener proceso (Ctrl+C)" ,
398+ ctxRevealExplorer : "Mostrar en el explorador de archivos" ,
399+ ctxCloseTab : "Cerrar pestaña" ,
391400 closeTabConfirm : "Hay un proceso en ejecución en esta pestaña. ¿Cerrarla de todas formas?" ,
392401 closeTabConfirmTitle : "Cerrar pestaña" ,
393402 closeTabConfirmOk : "Cerrar de todas formas" ,
@@ -529,6 +538,15 @@ const I18N: Record<Lang, Record<string, string>> = {
529538 filePreviewCopied : "Copied to clipboard" ,
530539 filePreviewSaved : "Saved" ,
531540 filePreviewSaveError : "Couldn't save the file" ,
541+ ctxPaste : "Paste" ,
542+ ctxSelectAll : "Select All" ,
543+ ctxFind : "Find…" ,
544+ ctxClear : "Clear Screen" ,
545+ ctxScrollBottom : "Scroll to Bottom" ,
546+ ctxCopyOutput : "Copy Command Output" ,
547+ ctxStopProcess : "Stop Process (Ctrl+C)" ,
548+ ctxRevealExplorer : "Show in File Explorer" ,
549+ ctxCloseTab : "Close Tab" ,
532550 closeTabConfirm : "This tab has a running process. Close it anyway?" ,
533551 closeTabConfirmTitle : "Close tab" ,
534552 closeTabConfirmOk : "Close anyway" ,
@@ -670,6 +688,15 @@ const I18N: Record<Lang, Record<string, string>> = {
670688 filePreviewCopied : "Copié dans le presse-papiers" ,
671689 filePreviewSaved : "Enregistré" ,
672690 filePreviewSaveError : "Impossible d'enregistrer le fichier" ,
691+ ctxPaste : "Coller" ,
692+ ctxSelectAll : "Tout sélectionner" ,
693+ ctxFind : "Rechercher…" ,
694+ ctxClear : "Effacer l'écran" ,
695+ ctxScrollBottom : "Aller en bas" ,
696+ ctxCopyOutput : "Copier la sortie de la commande" ,
697+ ctxStopProcess : "Arrêter le processus (Ctrl+C)" ,
698+ ctxRevealExplorer : "Afficher dans l'explorateur de fichiers" ,
699+ ctxCloseTab : "Fermer l'onglet" ,
673700 closeTabConfirm : "Un processus est en cours d'exécution dans cet onglet. Le fermer quand même ?" ,
674701 closeTabConfirmTitle : "Fermer l'onglet" ,
675702 closeTabConfirmOk : "Fermer quand même" ,
@@ -811,6 +838,15 @@ const I18N: Record<Lang, Record<string, string>> = {
811838 filePreviewCopied : "Copiato negli appunti" ,
812839 filePreviewSaved : "Salvato" ,
813840 filePreviewSaveError : "Impossibile salvare il file" ,
841+ ctxPaste : "Incolla" ,
842+ ctxSelectAll : "Seleziona tutto" ,
843+ ctxFind : "Trova…" ,
844+ ctxClear : "Pulisci schermo" ,
845+ ctxScrollBottom : "Vai in fondo" ,
846+ ctxCopyOutput : "Copia output del comando" ,
847+ ctxStopProcess : "Interrompi processo (Ctrl+C)" ,
848+ ctxRevealExplorer : "Mostra in Esplora file" ,
849+ ctxCloseTab : "Chiudi scheda" ,
814850 closeTabConfirm : "In questa scheda è in esecuzione un processo. Chiuderla comunque?" ,
815851 closeTabConfirmTitle : "Chiudi scheda" ,
816852 closeTabConfirmOk : "Chiudi comunque" ,
@@ -1595,12 +1631,50 @@ async function newSession(
15951631 pane . addEventListener ( "contextmenu" , ( ev ) => {
15961632 ev . preventDefault ( ) ;
15971633 const sel = term . getSelection ( ) ;
1598- if ( sel ) {
1599- clipWrite ( sel ) . catch ( ( ) => { } ) ;
1600- term . clearSelection ( ) ;
1601- } else {
1602- pasteFromClipboard ( ) ;
1603- }
1634+ const rowText = rowTextAtPoint ( pane , ev . clientY ) ;
1635+ const linkMatch = rowText ? FILE_LINK_RE . exec ( rowText ) : null ;
1636+
1637+ openTermContextMenu (
1638+ [
1639+ {
1640+ label : t ( "copy" ) ,
1641+ disabled : ! sel ,
1642+ onClick : ( ) => {
1643+ if ( sel ) clipWrite ( sel ) . catch ( ( ) => { } ) ;
1644+ term . clearSelection ( ) ;
1645+ } ,
1646+ } ,
1647+ { label : t ( "ctxPaste" ) , onClick : ( ) => pasteFromClipboard ( ) } ,
1648+ sep ,
1649+ {
1650+ label : t ( "ctxCopyOutput" ) ,
1651+ hidden : ! blocks . active ( ) ,
1652+ onClick : ( ) => blocks . copySelectedOutput ( ) ,
1653+ } ,
1654+ { label : t ( "ctxSelectAll" ) , onClick : ( ) => term . selectAll ( ) } ,
1655+ { label : t ( "ctxFind" ) , onClick : ( ) => openSearch ( ) } ,
1656+ { label : t ( "ctxScrollBottom" ) , onClick : ( ) => term . scrollToBottom ( ) } ,
1657+ { label : t ( "ctxClear" ) , onClick : ( ) => term . clear ( ) } ,
1658+ sep ,
1659+ {
1660+ label : t ( "ctxStopProcess" ) ,
1661+ hidden : ! session . alive ,
1662+ onClick : ( ) => invoke ( "write_pty" , { id, data : "\x03" } ) . catch ( ( ) => { } ) ,
1663+ } ,
1664+ {
1665+ label : t ( "ctxRevealExplorer" ) ,
1666+ hidden : ! linkMatch ,
1667+ onClick : ( ) => {
1668+ if ( linkMatch ) revealItemInDir ( resolveFilePath ( linkMatch [ 0 ] , cwd ) ) . catch ( ( ) => { } ) ;
1669+ } ,
1670+ } ,
1671+ sep ,
1672+ { label : t ( "tooltipNew" ) , onClick : ( ) => $ ( "#btn-new-tab" ) . click ( ) } ,
1673+ { label : t ( "ctxCloseTab" ) , onClick : ( ) => closeSession ( id ) } ,
1674+ ] ,
1675+ ev . clientX ,
1676+ ev . clientY ,
1677+ ) ;
16041678 } ) ;
16051679
16061680 const session : Session = {
@@ -2930,6 +3004,66 @@ document.addEventListener("click", (e) => {
29303004 if ( ! tabColorMenu . contains ( e . target as Node ) ) tabColorMenu . classList . add ( "hidden" ) ;
29313005} ) ;
29323006
3007+ // ── Terminal right-click menu ───────────────────────────────
3008+
3009+ // WebGL rendering draws rows to canvas, leaving no per-character DOM to hit
3010+ // -test — but xterm's screenReaderMode keeps one accessibility row div per
3011+ // line, positioned to match the real rendered row, which doubles as a free
3012+ // row-text-at-a-point lookup.
3013+ function rowTextAtPoint ( pane : HTMLElement , y : number ) : string {
3014+ const rows = pane . querySelectorAll ( ".xterm-accessibility-tree > *" ) ;
3015+ for ( const row of rows ) {
3016+ const r = row . getBoundingClientRect ( ) ;
3017+ if ( y >= r . top && y < r . bottom ) return row . textContent ?? "" ;
3018+ }
3019+ return "" ;
3020+ }
3021+
3022+ type CtxItem =
3023+ | "sep"
3024+ | { label : string ; onClick : ( ) => void ; disabled ?: boolean ; hidden ?: boolean } ;
3025+ const sep : CtxItem = "sep" ;
3026+
3027+ const termContextMenu = $ ( "#term-context-menu" ) ;
3028+
3029+ function openTermContextMenu ( items : CtxItem [ ] , x : number , y : number ) {
3030+ termContextMenu . replaceChildren ( ) ;
3031+ let pendingSep = false ;
3032+ for ( const item of items ) {
3033+ if ( item === "sep" ) {
3034+ pendingSep = termContextMenu . childElementCount > 0 ;
3035+ continue ;
3036+ }
3037+ if ( item . hidden ) continue ;
3038+ if ( pendingSep ) {
3039+ termContextMenu . appendChild ( document . createElement ( "div" ) ) . className = "term-context-sep" ;
3040+ pendingSep = false ;
3041+ }
3042+ const btn = document . createElement ( "button" ) ;
3043+ btn . className = "term-context-item" ;
3044+ btn . textContent = item . label ;
3045+ btn . disabled = ! ! item . disabled ;
3046+ btn . addEventListener ( "click" , ( ) => {
3047+ termContextMenu . classList . add ( "hidden" ) ;
3048+ item . onClick ( ) ;
3049+ } ) ;
3050+ termContextMenu . appendChild ( btn ) ;
3051+ }
3052+ // Clamp so a right-click near the window edge doesn't open off-screen.
3053+ termContextMenu . classList . remove ( "hidden" ) ;
3054+ const menuRect = termContextMenu . getBoundingClientRect ( ) ;
3055+ const left = Math . min ( x , window . innerWidth - menuRect . width - 8 ) ;
3056+ const top = Math . min ( y , window . innerHeight - menuRect . height - 8 ) ;
3057+ termContextMenu . style . left = `${ Math . max ( 8 , left ) } px` ;
3058+ termContextMenu . style . top = `${ Math . max ( 8 , top ) } px` ;
3059+ }
3060+ document . addEventListener ( "click" , ( e ) => {
3061+ if ( ! termContextMenu . contains ( e . target as Node ) ) termContextMenu . classList . add ( "hidden" ) ;
3062+ } ) ;
3063+ document . addEventListener ( "keydown" , ( e ) => {
3064+ if ( e . key === "Escape" ) termContextMenu . classList . add ( "hidden" ) ;
3065+ } ) ;
3066+
29333067$ ( "#btn-ghost" ) . addEventListener ( "click" , ( ) =>
29343068 invoke ( "set_ghost_mode" , { enabled : ! overlayEl . classList . contains ( "ghost" ) } ) ,
29353069) ;
@@ -3175,6 +3309,29 @@ const FILE_LINK_RE = new RegExp(
31753309 "i" ,
31763310) ;
31773311
3312+ // POSIX absolute paths start with "/" — only meaningful off Windows, where a
3313+ // leading "/" in agent output is instead likely a relative separator
3314+ // artifact and the drive-letter/UNC/~ checks do the work.
3315+ function isAbsolutePath ( raw : string ) : boolean {
3316+ const win = platform . os === "windows" ;
3317+ return (
3318+ / ^ [ A - Z a - z ] : [ \\ / ] / . test ( raw ) ||
3319+ raw . startsWith ( "\\\\" ) ||
3320+ raw . startsWith ( "~" ) ||
3321+ ( ! win && raw . startsWith ( "/" ) )
3322+ ) ;
3323+ }
3324+
3325+ // Resolves a bare/relative-looking path matched from terminal text against
3326+ // the session's cwd — shared by the file-preview panel and the terminal's
3327+ // right-click "Show in File Explorer" action.
3328+ function resolveFilePath ( raw : string , cwd : string | null ) : string {
3329+ if ( isAbsolutePath ( raw ) ) return raw ;
3330+ return platform . os === "windows"
3331+ ? `${ cwd ?? "." } \\${ raw } ` . replace ( / \/ / g, "\\" )
3332+ : `${ cwd ?? "." } /${ raw } ` ;
3333+ }
3334+
31783335// WebLinksAddon rejects any match that doesn't round-trip through `new
31793336// URL()`, which throws for a plain file path — it's built strictly for
31803337// http(s) links even when given a custom regex. A hand-rolled link
@@ -3371,20 +3528,7 @@ function setPreviewEditing(editing: boolean) {
33713528}
33723529
33733530async function openFilePreview ( raw : string , cwd : string | null ) {
3374- // POSIX absolute paths start with "/" — only meaningful off Windows,
3375- // where a leading "/" in agent output is instead likely a relative
3376- // separator artifact and the drive-letter/UNC/~ checks do the work.
3377- const win = platform . os === "windows" ;
3378- const isAbsolute =
3379- / ^ [ A - Z a - z ] : [ \\ / ] / . test ( raw ) ||
3380- raw . startsWith ( "\\\\" ) ||
3381- raw . startsWith ( "~" ) ||
3382- ( ! win && raw . startsWith ( "/" ) ) ;
3383- const path = isAbsolute
3384- ? raw
3385- : win
3386- ? `${ cwd ?? "." } \\${ raw } ` . replace ( / \/ / g, "\\" )
3387- : `${ cwd ?? "." } /${ raw } ` ;
3531+ const path = resolveFilePath ( raw , cwd ) ;
33883532 disposeModelPreview ( ) ;
33893533 previewPath = path ;
33903534 previewText = null ;
@@ -3394,6 +3538,11 @@ async function openFilePreview(raw: string, cwd: string | null) {
33943538 filePreviewBody . className = "file-preview-body plain" ;
33953539 filePreviewBody . textContent = "…" ;
33963540 filePreviewModal . classList . add ( "open" ) ;
3541+ // Move focus off the terminal and onto the panel: xterm's own keydown
3542+ // handler treats Escape as a key it must own (cancels the browser event
3543+ // unconditionally), which stops it from ever reaching our document-level
3544+ // Escape handler while the terminal textarea still has focus.
3545+ $ ( "#file-preview-close" ) . focus ( ) ;
33973546 ignoreNextOutsideClick = true ;
33983547 setTimeout ( ( ) => ( ignoreNextOutsideClick = false ) , 0 ) ;
33993548 const ext = / \. ( [ a - z 0 - 9 ] + ) $ / i. exec ( path ) ?. [ 1 ] . toLowerCase ( ) ;
@@ -3450,7 +3599,7 @@ async function openFilePreview(raw: string, cwd: string | null) {
34503599 // A bare name with no path separators (e.g. from a bullet list) was
34513600 // guessed relative to the session folder — say so instead of just
34523601 // surfacing a raw "file not found", since the real folder is unknown.
3453- const bareName = ! isAbsolute && ! / [ \\ / ] / . test ( raw ) ;
3602+ const bareName = ! isAbsolutePath ( raw ) && ! / [ \\ / ] / . test ( raw ) ;
34543603 filePreviewBody . textContent = bareName
34553604 ? `${ t ( "filePreviewNotFoundBare" ) } \n\n"${ raw } " — ${ t ( "filePreviewTriedIn" ) } ${ path } `
34563605 : `${ t ( "filePreviewError" ) } : ${ path } \n${ err } ` ;
0 commit comments