Compare commits

...
8 Commits
Author SHA1 Message Date
Bill fcb2781694 chore: bump version to 1.0.7 2026-09-14 20:12:04 +08:00
Bill 0b4a87f1e7 fix: feed pasted text into the line buffer so history and cwd memory see it
App-driven paste (context menu / Ctrl+Shift+V) writes straight to the pty,
bypassing xterm's onData, and xterm-native paste arrives wrapped in
bracketed-paste markers that the buffer guards dropped. Either way a pasted
'cd D:\path' was recorded as the bare 'cd' typed before it, so the pane's
directory memory silently stayed home. Track submitted lines from pasted
text and strip bracketed-paste markers in the line buffer.
2026-09-14 20:01:23 +08:00
Bill 01827bf70b fix: resolve drive-relative cd (cd d:) in cwd memory
path.isAbsolute('d:') is false on Windows, so a drive-relative cd resolved
against the current base, produced a nonexistent path, and the pane kept
its creation directory in the snapshot. Map a bare drive-letter argument
to the drive root (the fresh-shell answer; we cannot know the drive's
remembered directory).
2026-09-14 19:52:32 +08:00
Bill 9a330499de fix: track bare drive switches (d:) for cwd memory
PowerShell/cmd users hop drives with a bare 'd:' — no cd keyword — so the
typed-command tracker never saw it, the pane's cwd stayed at its creation
directory, and the session snapshot restored it to home. Treat a bare
drive-letter line as a cd to the drive root; the main-process existence
check drops it on platforms without drive letters.
2026-09-14 19:27:20 +08:00
Bill 3c193a48a7 chore: bump version to 1.0.6 2026-09-14 17:31:00 +08:00
Bill 9467b78560 chore: name installed NSIS shortcuts OTerminal 2026-09-14 17:22:15 +08:00
Bill be7332db17 perf: route PTY stream through a single shared IPC listener
Every pane registered its own global onPtyData/onPtyExit listener, so each
output chunk woke N listeners and N-1 discarded it after an id compare.
With many terminals under heavy output that fan-out is pure overhead.
Now one IPC listener dispatches through a Map keyed by sessionId: one
lookup per chunk, only the owning pane runs.
2026-09-14 16:59:57 +08:00
Bill bbee1d642d polish: about-page logo now uses the app icon instead of an OT text mark 2026-09-14 15:06:19 +08:00
8 changed files with 146 additions and 36 deletions

No files matched your search

+3
View File
@@ -37,6 +37,9 @@ nsis:
oneClick: false
allowToChangeInstallationDirectory: true
perMachine: false
# Installed shortcuts read "OTerminal"; productName stays OpenTerminal so the
# userData dir and window title are untouched.
shortcutName: OTerminal
createDesktopShortcut: true
createStartMenuShortcut: true
artifactName: ${productName}-${version}-setup.${ext}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.5",
"version": "1.0.7",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "Bill",
+7
View File
@@ -37,6 +37,13 @@ export function resolveCwd(current: string | undefined, rawArg: string): string
}
// `cd -` means the shell's previous directory, which we cannot know here.
if (arg === '-') return null
// Drive-relative `cd d:` (cmd/PowerShell): the target is the drive's
// current directory, which the shell never tells us — path.isAbsolute('d:')
// is false, so without this branch it resolves against the base and dies on
// a nonexistent `<base>\d:`. The drive root is the fresh-shell answer.
if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(arg)) {
return existingDir(`${arg.toUpperCase()}\\`)
}
const base = current && current.trim() ? current : homedir()
// An absolute argument wins; otherwise resolve against the base. Both forms
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Button, Progress, Select, Switch, Tag } from 'antd'
import type { AppInfo, ReleaseNote, UpdateState, UpdateStatus } from '@shared/ipc'
import { useSettingsStore } from './store'
import appIconUrl from '../../../../build/icon.png'
const STATUS_TEXT: Record<UpdateStatus, string> = {
idle: '点击检查更新',
@@ -34,7 +35,7 @@ export function AboutTab(): React.JSX.Element {
return (
<div className="about-tab">
<div className="about-header">
<div className="about-logo">OT</div>
<img className="about-logo" src={appIconUrl} alt="OpenTerminal" draggable={false} />
<div>
<div className="about-name">OpenTerminal</div>
<div className="about-version">
+2 -7
View File
@@ -380,13 +380,8 @@
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--chrome-hover);
color: #3fd06a;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
object-fit: cover;
display: block;
}
.about-name {
+65 -22
View File
@@ -9,11 +9,11 @@ import '@xterm/xterm/css/xterm.css'
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
import type { TerminalSettings } from '@shared/settings'
import { useSettingsStore } from '@renderer/settings/store'
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine'
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
import { cdArgument, conemuCwd } from './cwdTracker'
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
import './terminal.css'
@@ -152,6 +152,15 @@ function stepLineBuffer(
outBuf.current = ''
return { line: '' }
}
// xterm-native paste (plain Ctrl+V): with the shell's bracketed-paste mode
// on, the pasted block arrives wrapped in \x1b[200~ / \x1b[201~ and the chunk
// starts with ESC, which the guards below would drop wholesale. Strip the
// markers and keep the pasted text as ordinary input.
if (data.includes('\x1b[200~') || data.includes('\x1b[201~')) {
const text = data.replace(/\x1b\[20[01]~/g, '')
if (text) outBuf.current = b + text
return { line: outBuf.current }
}
// A chunk that begins with escape/control sequences (e.g. the terminal's
// clear-line) only removes buffer content; never appends.
const first = data[0]
@@ -429,15 +438,59 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
setResultInfo(found ? '已找到' : '未找到')
}, [])
/** Record a submitted command line; cd-style lines also update cwd memory. */
const trackSubmittedLine = useCallback(
(submit: string): void => {
void window.api.recordCommand(submit).catch(() => undefined)
// Remember the directory for the next launch: a cd-style line is
// resolved in the main process (the only side with node's `path`).
const cdArg = cdArgument(submit)
if (cdArg !== null) {
void window.api
.resolveCwd(getSessionCwd(sessionId), cdArg)
.then((next: string | null) => {
if (next) setSessionCwd(sessionId, next)
})
.catch(() => undefined)
}
},
[sessionId]
)
/**
* Pasted text is written straight to the pty (confirmPaste → writeBroadcast),
* bypassing xterm's onData, so the line buffer / history / cwd tracker would
* never see it — a pasted `cd X` used to be recorded as the bare prefix typed
* before it. Mirror the shell: each completed line is a submitted command,
* the trailing partial line stays pending in the buffer.
*/
const trackPastedText = useCallback(
(data: string): void => {
if (termRef.current?.buffer.active.type === 'alternate') return
const parts = data.replace(/\r\n/g, '\n').split('\n')
const tail = parts.pop() ?? ''
for (const line of parts) {
const t = line.trim()
lineBufRef.current = ''
if (t) trackSubmittedLine(t)
}
if (tail) lineBufRef.current += tail
},
[trackSubmittedLine]
)
const confirmPaste = useCallback(
(text: string) => {
const data = nativePasteRef.current ?? text
nativePasteRef.current = null
setPaste(null)
if (!deadRef.current) writeBroadcast(sessionId, data)
if (!deadRef.current) {
writeBroadcast(sessionId, data)
trackPastedText(data)
}
termRef.current?.focus()
},
[sessionId]
[sessionId, trackPastedText]
)
const cancelPaste = useCallback(() => {
@@ -732,18 +785,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// M5: line capture → recordCommand + inline completion overlay.
const step = stepLineBuffer(lineBufRef, data, consumeRewrite)
if (typeof step.submit === 'string' && step.submit) {
void window.api.recordCommand(step.submit).catch(() => undefined)
// Remember the directory for the next launch: a cd-style line is
// resolved in the main process (the only side with node's `path`).
const cdArg = cdArgument(step.submit)
if (cdArg !== null) {
void window.api
.resolveCwd(getSessionCwd(sessionId), cdArg)
.then((next: string | null) => {
if (next) setSessionCwd(sessionId, next)
})
.catch(() => undefined)
}
trackSubmittedLine(step.submit)
}
if (deadRef.current) {
suggestionsRef.current = []
@@ -792,18 +834,19 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
let replayDone = false
const pending: string[] = []
const unsubscribes: (() => void)[] = [
window.api.onPtyData((e: PtyDataEvent) => {
if (e.id !== sessionId || deadRef.current) return
// Routed via the shared dispatcher (one IPC listener for all panes)
// instead of a per-pane global listener.
subscribePtyData(sessionId, (data: string) => {
if (deadRef.current) return
if (!replayDone) {
pending.push(e.data)
pending.push(data)
return
}
writeHighlighted(e.data)
writeHighlighted(data)
}),
window.api.onPtyExit((e: PtyExitEvent) => {
if (e.id !== sessionId) return
subscribePtyExit(sessionId, (code: number) => {
deadRef.current = true
setExitCode(e.exitCode)
setExitCode(code)
setDead(true)
})
]
+18 -5
View File
@@ -4,8 +4,9 @@
* Two signals feed the same store:
* 1. the shell itself, over OSC 7 (`file://host/path`) or ConEmu's OSC 9;9 —
* exact, but only present when shell integration is on;
* 2. the command the user typed, matched against the `cd` family — the
* fallback that works with no shell cooperation at all.
* 2. the command the user typed, matched against the `cd` family plus the
* bare drive switch (`d:`) of cmd.exe / PowerShell — the fallback that
* works with no shell cooperation at all.
*
* Both are platform-neutral: path resolution and existence checks happen in the
* main process (`src/main/cwd.ts`) where node's `path` lives, so nothing here
@@ -21,14 +22,26 @@
*/
const CD_COMMAND = /^\s*(?:cd|chdir|sl|set-location|pushd|push-location)(?:\s+([\s\S]*))?$/i
/**
* Bare drive switch (`d:`, `D:`) — cmd.exe and PowerShell change the current
* drive with no `cd` keyword at all, and it is the common way to land on
* another drive on Windows. Treated as a cd to the drive root; the main
* process's existence check quietly drops it on platforms where drive
* letters mean nothing (POSIX resolves it to a nonexistent relative path).
*/
const DRIVE_SWITCH = /^\s*([a-zA-Z]):\s*$/
/**
* Return the argument of a cd-style command line, or null when the line is not
* one. An empty string means "bare cd" (the shell's home directory).
* one. An empty string means "bare cd" (the shell's home directory). A bare
* drive switch yields the drive root (`D:\`).
*/
export function cdArgument(line: string): string | null {
const match = line.match(CD_COMMAND)
if (!match) return null
return (match[1] ?? '').trim()
if (match) return (match[1] ?? '').trim()
const drive = line.match(DRIVE_SWITCH)
if (drive) return `${drive[1].toUpperCase()}:\\`
return null
}
/** Payload of an OSC 9 sequence that follows the ConEmu `9;<path>` convention. */
@@ -0,0 +1,48 @@
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
/**
* Single shared PTY stream listener. Every pane used to register its own
* window.api.onPtyData/onPtyExit listener, so each output chunk woke N
* listeners (N = pane count) and N-1 of them discarded it after an id
* compare. With many terminals under heavy output that per-chunk fan-out is
* pure overhead. Here one IPC listener dispatches through a Map keyed by
* sessionId: one lookup per chunk, and only the owning pane runs.
*/
type DataHandler = (data: string) => void
type ExitHandler = (exitCode: number) => void
const dataHandlers = new Map<string, DataHandler>()
const exitHandlers = new Map<string, ExitHandler>()
let bound = false
function ensureBound(): void {
if (bound) return
bound = true
window.api.onPtyData((e: PtyDataEvent) => {
dataHandlers.get(e.id)?.(e.data)
})
window.api.onPtyExit((e: PtyExitEvent) => {
exitHandlers.get(e.id)?.(e.exitCode)
})
}
/** Subscribe to one session's output. Returns the unsubscribe function. */
export function subscribePtyData(id: string, cb: DataHandler): () => void {
ensureBound()
dataHandlers.set(id, cb)
return () => {
// A rebind (template apply) may have replaced the handler already; only
// delete when this subscription is still the registered one.
if (dataHandlers.get(id) === cb) dataHandlers.delete(id)
}
}
/** Subscribe to one session's exit. Returns the unsubscribe function. */
export function subscribePtyExit(id: string, cb: ExitHandler): () => void {
ensureBound()
exitHandlers.set(id, cb)
return () => {
if (exitHandlers.get(id) === cb) exitHandlers.delete(id)
}
}