From c20a951bbd8d5b71dab8e5ebb8f34703783d2885 Mon Sep 17 00:00:00 2001 From: Optuber01 Date: Wed, 15 Apr 2026 18:23:10 +0300 Subject: [PATCH] feat: improve terminal and markdown link behavior --- .../components/Markdown/MarkdownPane.tsx | 40 +++++++++++++- src/renderer/hooks/useTerminal.ts | 54 +++++++++++++++---- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/renderer/components/Markdown/MarkdownPane.tsx b/src/renderer/components/Markdown/MarkdownPane.tsx index 54c46b61..db092bea 100644 --- a/src/renderer/components/Markdown/MarkdownPane.tsx +++ b/src/renderer/components/Markdown/MarkdownPane.tsx @@ -1,5 +1,7 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { marked } from 'marked'; +import { SplitNode } from '../../../shared/types'; +import { useStore } from '../../store'; import '../../styles/markdown.css'; interface MarkdownPaneProps { @@ -7,6 +9,13 @@ interface MarkdownPaneProps { surfaceId: string; } +function treeHasSurface(node: SplitNode, surfaceId: string): boolean { + if (node.type === 'leaf') return node.surfaces.some((surface) => surface.id === surfaceId); + return treeHasSurface(node.children[0], surfaceId) || treeHasSurface(node.children[1], surfaceId); +} + +const LINK_HINT = 'Click to open in wmux browser. Ctrl+Click opens your default browser.'; + export default function MarkdownPane({ content = '', surfaceId }: MarkdownPaneProps) { const html = useMemo(() => { if (!content) return '

No content. Use wmux markdown set to add content.

'; @@ -19,10 +28,39 @@ export default function MarkdownPane({ content = '', surfaceId }: MarkdownPanePr return marked.parse(content) as string; }, [content]); + const handleContentClick = useCallback((event: React.MouseEvent) => { + const anchor = (event.target as HTMLElement | null)?.closest?.('a') as HTMLAnchorElement | null; + const href = anchor?.href; + if (!href) return; + + event.preventDefault(); + anchor.title = LINK_HINT; + + const state = useStore.getState(); + const workspace = state.workspaces.find((ws) => treeHasSurface(ws.splitTree, surfaceId)); + const openExternal = event.ctrlKey || event.metaKey; + + if (openExternal || !workspace || !workspace.browserOpen) { + window.wmux?.system?.openExternal?.(href); + return; + } + + state.updateWorkspaceMetadata(workspace.id, { + browserOpen: true, + browserUrl: href, + }); + window.wmux?.browser?.navigate?.(`browser-${workspace.id}`, href); + }, [surfaceId]); + return (
{ + const anchor = (event.target as HTMLElement | null)?.closest?.('a') as HTMLAnchorElement | null; + if (anchor?.href) anchor.title = LINK_HINT; + }} + onClick={handleContentClick} dangerouslySetInnerHTML={{ __html: html }} />
diff --git a/src/renderer/hooks/useTerminal.ts b/src/renderer/hooks/useTerminal.ts index a4ed5f8f..fa1ed708 100644 --- a/src/renderer/hooks/useTerminal.ts +++ b/src/renderer/hooks/useTerminal.ts @@ -6,6 +6,8 @@ import { WebLinksAddon } from '@xterm/addon-web-links'; import { SearchAddon } from '@xterm/addon-search'; import { Unicode11Addon } from '@xterm/addon-unicode11'; import { ImageAddon } from '@xterm/addon-image'; +import { useStore } from '../store'; +import { SplitNode } from '../../shared/types'; import '@xterm/xterm/css/xterm.css'; declare global { @@ -29,6 +31,35 @@ interface UseTerminalResult { searchAddonRef: React.RefObject; } +function treeHasSurface(node: SplitNode, surfaceId: string): boolean { + if (node.type === 'leaf') return node.surfaces.some((surface) => surface.id === surfaceId); + return treeHasSurface(node.children[0], surfaceId) || treeHasSurface(node.children[1], surfaceId); +} + +function getWorkspaceForSurface(surfaceId?: string) { + if (!surfaceId) return null; + const { workspaces } = useStore.getState(); + return workspaces.find((ws) => treeHasSurface(ws.splitTree, surfaceId)) ?? null; +} + +const LINK_HINT = 'Click to open in wmux browser. Ctrl+Click opens your default browser.'; + +function openLinkForSurface(uri: string, surfaceId?: string, openExternal = false): void { + const state = useStore.getState(); + const workspace = getWorkspaceForSurface(surfaceId); + + if (openExternal || !workspace || !workspace.browserOpen) { + window.wmux?.system?.openExternal?.(uri); + return; + } + + state.updateWorkspaceMetadata(workspace.id, { + browserOpen: true, + browserUrl: uri, + }); + window.wmux?.browser?.navigate?.(`browser-${workspace.id}`, uri); +} + export function useTerminal({ surfaceId, shell, cwd, visible = true }: UseTerminalOptions = {}): UseTerminalResult { const terminalRef = useRef(null); const xtermRef = useRef(null); @@ -72,17 +103,9 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true }: UseTermin // Create and load addons const fitAddon = new FitAddon(); - const webLinksAddon = new WebLinksAddon((_event, uri) => { - // Intercept localhost URLs → open in browser panel instead of system browser - try { - const url = new URL(uri); - if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '0.0.0.0') { - window.wmux?.browser?.navigate?.('', uri); - return; - } - } catch {} - // Non-localhost URLs → open in system browser - window.wmux?.system?.openExternal?.(uri); + const webLinksAddon = new WebLinksAddon((event, uri) => { + const openExternal = !!event?.ctrlKey || !!event?.metaKey; + openLinkForSurface(uri, surfaceId, openExternal); }); const searchAddon = new SearchAddon(); const unicode11Addon = new Unicode11Addon(); @@ -101,6 +124,14 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true }: UseTermin // Open terminal in the DOM terminal.open(terminalRef.current); + const handleLinkHover = (event: MouseEvent) => { + const anchor = (event.target as HTMLElement | null)?.closest?.('a') as HTMLAnchorElement | null; + if (anchor?.href) { + anchor.title = LINK_HINT; + } + }; + terminalRef.current.addEventListener('mouseover', handleLinkHover); + // Korean/CJK IME reliability fix. // xterm.js 5.5's CompositionHelper._finalizeComposition defers reading the // textarea via setTimeout(0), which races against fast Hangul composition @@ -299,6 +330,7 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true }: UseTermin // Cleanup return () => { + terminalRef.current?.removeEventListener('mouseover', handleLinkHover); resizeObserver.disconnect(); if (resizeRaf !== null) cancelAnimationFrame(resizeRaf); dataDisposable.dispose();