mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-31 10:25:57 +00:00
Add terminal max width control and fix WebSocket panic (#29)
* feat: add terminal max width option - Add terminal preferences manager for persistent settings storage - Add maxCols property to terminal component with width constraint logic - Add UI toggle button (∞/80) in session header for easy width control - Default behavior unchanged: unlimited width (takes full container) - Optional 80-column max width limit when enabled - Preferences saved to localStorage and restored on page load - Real-time updates without page refresh 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: enhance terminal width selector with common presets and custom input - Add common terminal width presets: ∞, 80, 100, 120, 132, 160 - Add custom width input field (20-500 columns) - Replace simple toggle with dropdown selector UI - Include helpful descriptions for each preset - Support keyboard shortcuts (Enter to submit, Escape to cancel) - Add click-outside-to-close functionality - Maintain all existing preferences persistence - Show current width in button label and tooltip Common widths: - 80: Classic terminal - 100: Modern standard - 120: Wide terminal - 132: Mainframe width - 160: Ultra-wide - Custom: User-defined (20-500) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: prevent WebSocket send on closed channel panic Added safeSend helper function with panic recovery to handle race conditions when multiple goroutines access WebSocket channels. Replaces unsafe channel sends with graceful error handling. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c6b000a421
commit
9792908fe1
5 changed files with 326 additions and 81 deletions
|
|
@ -20,7 +20,7 @@ import (
|
|||
const (
|
||||
// Magic byte for binary messages
|
||||
BufferMagicByte = 0xbf
|
||||
|
||||
|
||||
// WebSocket timeouts
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
|
|
@ -48,6 +48,22 @@ func NewBufferWebSocketHandler(manager *session.Manager) *BufferWebSocketHandler
|
|||
}
|
||||
}
|
||||
|
||||
// safeSend safely sends data to a channel, returning false if the channel is closed
|
||||
func safeSend(send chan []byte, data []byte, done chan struct{}) bool {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Channel was closed, ignore the panic
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case send <- data:
|
||||
return true
|
||||
case <-done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *BufferWebSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
|
|
@ -59,9 +75,9 @@ func (h *BufferWebSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
|||
// Set up connection parameters
|
||||
conn.SetReadLimit(maxMessageSize)
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
|
||||
// Start ping ticker
|
||||
|
|
@ -113,9 +129,7 @@ func (h *BufferWebSocketHandler) handleTextMessage(conn *websocket.Conn, message
|
|||
case "ping":
|
||||
// Send pong response
|
||||
pong, _ := json.Marshal(map[string]string{"type": "pong"})
|
||||
select {
|
||||
case send <- pong:
|
||||
case <-done:
|
||||
if !safeSend(send, pong, done) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +138,7 @@ func (h *BufferWebSocketHandler) handleTextMessage(conn *websocket.Conn, message
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Start streaming session data
|
||||
go h.streamSession(sessionID, send, done)
|
||||
|
||||
|
|
@ -142,10 +156,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt
|
|||
"type": "error",
|
||||
"message": fmt.Sprintf("Session not found: %v", err),
|
||||
})
|
||||
select {
|
||||
case send <- errorMsg:
|
||||
case <-done:
|
||||
}
|
||||
safeSend(send, errorMsg, done)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -163,10 +174,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt
|
|||
"type": "error",
|
||||
"message": "Session stream not available",
|
||||
})
|
||||
select {
|
||||
case send <- errorMsg:
|
||||
case <-done:
|
||||
}
|
||||
safeSend(send, errorMsg, done)
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
|
@ -180,10 +188,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt
|
|||
"type": "error",
|
||||
"message": "Failed to create file watcher",
|
||||
})
|
||||
select {
|
||||
case send <- errorMsg:
|
||||
case <-done:
|
||||
}
|
||||
safeSend(send, errorMsg, done)
|
||||
return
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
|
@ -196,10 +201,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt
|
|||
"type": "error",
|
||||
"message": fmt.Sprintf("Failed to watch session stream: %v", err),
|
||||
})
|
||||
select {
|
||||
case send <- errorMsg:
|
||||
case <-done:
|
||||
}
|
||||
safeSend(send, errorMsg, done)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -214,7 +216,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt
|
|||
select {
|
||||
case <-done:
|
||||
return
|
||||
|
||||
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
|
|
@ -235,10 +237,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt
|
|||
if !sess.IsAlive() {
|
||||
// Send exit event
|
||||
exitMsg := h.createBinaryMessage(sessionID, []byte(`{"type":"exit","code":0}`))
|
||||
select {
|
||||
case send <- exitMsg:
|
||||
case <-done:
|
||||
}
|
||||
safeSend(send, exitMsg, done)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -307,14 +306,12 @@ func (h *BufferWebSocketHandler) processAndSendContent(sessionID, streamPath str
|
|||
*headerSent = true
|
||||
// Send header as binary message
|
||||
headerData, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "header",
|
||||
"width": header.Width,
|
||||
"type": "header",
|
||||
"width": header.Width,
|
||||
"height": header.Height,
|
||||
})
|
||||
msg := h.createBinaryMessage(sessionID, headerData)
|
||||
select {
|
||||
case send <- msg:
|
||||
case <-done:
|
||||
if !safeSend(send, msg, done) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
|
|
@ -331,29 +328,25 @@ func (h *BufferWebSocketHandler) processAndSendContent(sessionID, streamPath str
|
|||
if ok1 && ok2 && ok3 && eventType == "o" {
|
||||
// Create terminal output message
|
||||
outputData, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "output",
|
||||
"type": "output",
|
||||
"timestamp": timestamp,
|
||||
"data": data,
|
||||
"data": data,
|
||||
})
|
||||
|
||||
|
||||
msg := h.createBinaryMessage(sessionID, outputData)
|
||||
select {
|
||||
case send <- msg:
|
||||
case <-done:
|
||||
if !safeSend(send, msg, done) {
|
||||
return
|
||||
}
|
||||
} else if ok1 && ok2 && ok3 && eventType == "r" {
|
||||
// Create resize message
|
||||
resizeData, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "resize",
|
||||
"timestamp": timestamp,
|
||||
"type": "resize",
|
||||
"timestamp": timestamp,
|
||||
"dimensions": data,
|
||||
})
|
||||
|
||||
|
||||
msg := h.createBinaryMessage(sessionID, resizeData)
|
||||
select {
|
||||
case send <- msg:
|
||||
case <-done:
|
||||
if !safeSend(send, msg, done) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -364,34 +357,34 @@ func (h *BufferWebSocketHandler) processAndSendContent(sessionID, streamPath str
|
|||
func (h *BufferWebSocketHandler) createBinaryMessage(sessionID string, data []byte) []byte {
|
||||
// Binary message format:
|
||||
// [magic byte (1)] [session ID length (4, little endian)] [session ID] [data]
|
||||
|
||||
|
||||
sessionIDBytes := []byte(sessionID)
|
||||
totalLen := 1 + 4 + len(sessionIDBytes) + len(data)
|
||||
|
||||
|
||||
msg := make([]byte, totalLen)
|
||||
offset := 0
|
||||
|
||||
|
||||
// Magic byte
|
||||
msg[offset] = BufferMagicByte
|
||||
offset++
|
||||
|
||||
|
||||
// Session ID length (little endian)
|
||||
binary.LittleEndian.PutUint32(msg[offset:], uint32(len(sessionIDBytes)))
|
||||
offset += 4
|
||||
|
||||
|
||||
// Session ID
|
||||
copy(msg[offset:], sessionIDBytes)
|
||||
offset += len(sessionIDBytes)
|
||||
|
||||
|
||||
// Data
|
||||
copy(msg[offset:], data)
|
||||
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
func (h *BufferWebSocketHandler) writer(conn *websocket.Conn, send chan []byte, ticker *time.Ticker, done chan struct{}) {
|
||||
defer close(send)
|
||||
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-send:
|
||||
|
|
@ -419,9 +412,9 @@ func (h *BufferWebSocketHandler) writer(conn *websocket.Conn, send chan []byte,
|
|||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,8 +238,12 @@ export class FileBrowser extends LitElement {
|
|||
: ''}
|
||||
|
||||
<div class="p-4 border-t border-dark-border flex gap-4 flex-shrink-0">
|
||||
<button class="btn-ghost font-mono flex-1 py-3" @click=${this.handleCancel}>Cancel</button>
|
||||
<button class="btn-primary font-mono flex-1 py-3" @click=${this.handleSelect}>Select</button>
|
||||
<button class="btn-ghost font-mono flex-1 py-3" @click=${this.handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary font-mono flex-1 py-3" @click=${this.handleSelect}>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import type { Session } from './session-list.js';
|
|||
import './terminal.js';
|
||||
import type { Terminal } from './terminal.js';
|
||||
import { CastConverter } from '../utils/cast-converter.js';
|
||||
import {
|
||||
TerminalPreferencesManager,
|
||||
COMMON_TERMINAL_WIDTHS,
|
||||
} from '../utils/terminal-preferences.js';
|
||||
|
||||
@customElement('session-view')
|
||||
export class SessionView extends LitElement {
|
||||
|
|
@ -28,6 +32,11 @@ export class SessionView extends LitElement {
|
|||
@state() private terminalRows = 0;
|
||||
@state() private showCtrlAlpha = false;
|
||||
@state() private terminalFitHorizontally = false;
|
||||
@state() private terminalMaxCols = 0;
|
||||
@state() private showWidthSelector = false;
|
||||
@state() private customWidth = '';
|
||||
|
||||
private preferencesManager = TerminalPreferencesManager.getInstance();
|
||||
@state() private reconnectCount = 0;
|
||||
@state() private ctrlSequence: string[] = [];
|
||||
|
||||
|
|
@ -115,14 +124,33 @@ export class SessionView extends LitElement {
|
|||
}
|
||||
};
|
||||
|
||||
private handleClickOutside = (e: Event) => {
|
||||
if (this.showWidthSelector) {
|
||||
const target = e.target as HTMLElement;
|
||||
const widthSelector = this.querySelector('.width-selector-container');
|
||||
const widthButton = this.querySelector('.width-selector-button');
|
||||
|
||||
if (!widthSelector?.contains(target) && !widthButton?.contains(target)) {
|
||||
this.showWidthSelector = false;
|
||||
this.customWidth = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.connected = true;
|
||||
|
||||
// Load terminal preferences
|
||||
this.terminalMaxCols = this.preferencesManager.getMaxCols();
|
||||
|
||||
// Make session-view focusable
|
||||
this.tabIndex = 0;
|
||||
this.addEventListener('click', () => this.focus());
|
||||
|
||||
// Add click outside handler for width selector
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
|
||||
// Show loading animation if no session yet
|
||||
if (!this.session) {
|
||||
this.startLoading();
|
||||
|
|
@ -149,6 +177,9 @@ export class SessionView extends LitElement {
|
|||
super.disconnectedCallback();
|
||||
this.connected = false;
|
||||
|
||||
// Remove click outside handler
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
|
||||
// Remove click handler
|
||||
this.removeEventListener('click', () => this.focus());
|
||||
|
||||
|
|
@ -218,6 +249,7 @@ export class SessionView extends LitElement {
|
|||
this.terminal.rows = 24;
|
||||
this.terminal.fontSize = 14;
|
||||
this.terminal.fitHorizontally = false; // Allow natural terminal sizing
|
||||
this.terminal.maxCols = this.terminalMaxCols; // Apply saved max width preference
|
||||
|
||||
// Listen for session exit events
|
||||
this.terminal.addEventListener(
|
||||
|
|
@ -749,6 +781,52 @@ export class SessionView extends LitElement {
|
|||
}
|
||||
}
|
||||
|
||||
private handleMaxWidthToggle() {
|
||||
this.showWidthSelector = !this.showWidthSelector;
|
||||
}
|
||||
|
||||
private handleWidthSelect(newMaxCols: number) {
|
||||
this.terminalMaxCols = newMaxCols;
|
||||
this.preferencesManager.setMaxCols(newMaxCols);
|
||||
this.showWidthSelector = false;
|
||||
|
||||
// Update the terminal component
|
||||
const terminal = this.querySelector('vibe-terminal') as Terminal;
|
||||
if (terminal) {
|
||||
terminal.maxCols = newMaxCols;
|
||||
// Trigger a resize to apply the new constraint
|
||||
terminal.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private handleCustomWidthInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.customWidth = input.value;
|
||||
}
|
||||
|
||||
private handleCustomWidthSubmit() {
|
||||
const width = parseInt(this.customWidth, 10);
|
||||
if (!isNaN(width) && width >= 20 && width <= 500) {
|
||||
this.handleWidthSelect(width);
|
||||
this.customWidth = '';
|
||||
}
|
||||
}
|
||||
|
||||
private handleCustomWidthKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
this.handleCustomWidthSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
this.customWidth = '';
|
||||
this.showWidthSelector = false;
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentWidthLabel(): string {
|
||||
if (this.terminalMaxCols === 0) return '∞';
|
||||
const commonWidth = COMMON_TERMINAL_WIDTHS.find((w) => w.value === this.terminalMaxCols);
|
||||
return commonWidth ? commonWidth.label : this.terminalMaxCols.toString();
|
||||
}
|
||||
|
||||
private async sendInputText(text: string) {
|
||||
if (!this.session) return;
|
||||
|
||||
|
|
@ -885,7 +963,65 @@ export class SessionView extends LitElement {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs flex-shrink-0 ml-2">
|
||||
<div class="flex items-center gap-2 text-xs flex-shrink-0 ml-2 relative">
|
||||
<button
|
||||
class="btn-secondary font-mono text-xs px-2 py-1 flex-shrink-0 width-selector-button"
|
||||
@click=${this.handleMaxWidthToggle}
|
||||
title="Terminal width: ${this.terminalMaxCols === 0
|
||||
? 'Unlimited'
|
||||
: this.terminalMaxCols + ' columns'}"
|
||||
>
|
||||
${this.getCurrentWidthLabel()}
|
||||
</button>
|
||||
${this.showWidthSelector
|
||||
? html`
|
||||
<div
|
||||
class="width-selector-container absolute top-8 right-0 bg-dark-bg-secondary border border-dark-border rounded-md shadow-lg z-50 min-w-48"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="text-xs text-dark-text-muted mb-2 px-2">Terminal Width</div>
|
||||
${COMMON_TERMINAL_WIDTHS.map(
|
||||
(width) => html`
|
||||
<button
|
||||
class="w-full text-left px-2 py-1 text-xs hover:bg-dark-border rounded-sm flex justify-between items-center
|
||||
${this.terminalMaxCols === width.value
|
||||
? 'bg-dark-border text-accent-green'
|
||||
: 'text-dark-text'}"
|
||||
@click=${() => this.handleWidthSelect(width.value)}
|
||||
>
|
||||
<span class="font-mono">${width.label}</span>
|
||||
<span class="text-dark-text-muted text-xs">${width.description}</span>
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
<div class="border-t border-dark-border mt-2 pt-2">
|
||||
<div class="text-xs text-dark-text-muted mb-1 px-2">Custom (20-500)</div>
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min="20"
|
||||
max="500"
|
||||
placeholder="80"
|
||||
.value=${this.customWidth}
|
||||
@input=${this.handleCustomWidthInput}
|
||||
@keydown=${this.handleCustomWidthKeydown}
|
||||
class="flex-1 bg-dark-bg border border-dark-border rounded px-2 py-1 text-xs font-mono text-dark-text"
|
||||
/>
|
||||
<button
|
||||
class="btn-secondary text-xs px-2 py-1"
|
||||
@click=${this.handleCustomWidthSubmit}
|
||||
?disabled=${!this.customWidth ||
|
||||
parseInt(this.customWidth) < 20 ||
|
||||
parseInt(this.customWidth) > 500}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
<div class="flex flex-col items-end gap-0">
|
||||
<span class="${this.getStatusColor()} text-xs flex items-center gap-1">
|
||||
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
|
||||
|
|
@ -931,6 +1067,7 @@ export class SessionView extends LitElement {
|
|||
.rows=${24}
|
||||
.fontSize=${14}
|
||||
.fitHorizontally=${false}
|
||||
.maxCols=${this.terminalMaxCols}
|
||||
class="w-full h-full"
|
||||
></vibe-terminal>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LitElement, html } from 'lit';
|
||||
import { LitElement, html, PropertyValues } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/headless';
|
||||
import { UrlHighlighter } from '../utils/url-highlighter.js';
|
||||
|
|
@ -15,6 +15,7 @@ export class Terminal extends LitElement {
|
|||
@property({ type: Number }) rows = 24;
|
||||
@property({ type: Number }) fontSize = 14;
|
||||
@property({ type: Boolean }) fitHorizontally = false;
|
||||
@property({ type: Number }) maxCols = 0; // 0 means no limit
|
||||
|
||||
private originalFontSize: number = 14;
|
||||
|
||||
|
|
@ -87,6 +88,33 @@ export class Terminal extends LitElement {
|
|||
this.debugMode = new URLSearchParams(window.location.search).has('debug');
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues) {
|
||||
if (changedProperties.has('cols') || changedProperties.has('rows')) {
|
||||
if (this.terminal) {
|
||||
this.reinitializeTerminal();
|
||||
}
|
||||
}
|
||||
if (changedProperties.has('fontSize')) {
|
||||
// Store original font size when it changes (but not during horizontal fitting)
|
||||
if (!this.fitHorizontally) {
|
||||
this.originalFontSize = this.fontSize;
|
||||
}
|
||||
}
|
||||
if (changedProperties.has('fitHorizontally')) {
|
||||
if (!this.fitHorizontally) {
|
||||
// Restore original font size when turning off horizontal fitting
|
||||
this.fontSize = this.originalFontSize;
|
||||
}
|
||||
this.fitTerminal();
|
||||
}
|
||||
// If maxCols changed, trigger a resize
|
||||
if (changedProperties.has('maxCols')) {
|
||||
if (this.terminal && this.container) {
|
||||
this.fitTerminal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.cleanup();
|
||||
super.disconnectedCallback();
|
||||
|
|
@ -110,27 +138,6 @@ export class Terminal extends LitElement {
|
|||
}
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('cols') || changedProperties.has('rows')) {
|
||||
if (this.terminal) {
|
||||
this.reinitializeTerminal();
|
||||
}
|
||||
}
|
||||
if (changedProperties.has('fontSize')) {
|
||||
// Store original font size when it changes (but not during horizontal fitting)
|
||||
if (!this.fitHorizontally) {
|
||||
this.originalFontSize = this.fontSize;
|
||||
}
|
||||
}
|
||||
if (changedProperties.has('fitHorizontally')) {
|
||||
if (!this.fitHorizontally) {
|
||||
// Restore original font size when turning off horizontal fitting
|
||||
this.fontSize = this.originalFontSize;
|
||||
}
|
||||
this.fitTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
// Store the initial font size as original
|
||||
this.originalFontSize = this.fontSize;
|
||||
|
|
@ -303,7 +310,9 @@ export class Terminal extends LitElement {
|
|||
const lineHeight = this.fontSize * 1.2;
|
||||
const charWidth = this.measureCharacterWidth();
|
||||
|
||||
this.cols = Math.max(20, Math.floor(containerWidth / charWidth)) - 1; // This -1 should not be needed, but it is...
|
||||
const calculatedCols = Math.max(20, Math.floor(containerWidth / charWidth)) - 1; // This -1 should not be needed, but it is...
|
||||
// Apply maxCols constraint if set (0 means no limit)
|
||||
this.cols = this.maxCols > 0 ? Math.min(calculatedCols, this.maxCols) : calculatedCols;
|
||||
this.rows = Math.max(6, Math.floor(containerHeight / lineHeight));
|
||||
this.actualRows = this.rows;
|
||||
|
||||
|
|
|
|||
102
web/src/client/utils/terminal-preferences.ts
Normal file
102
web/src/client/utils/terminal-preferences.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Terminal preferences management
|
||||
* Handles saving and loading terminal-related user preferences
|
||||
*/
|
||||
|
||||
export interface TerminalPreferences {
|
||||
maxCols: number; // 0 means no limit, positive numbers set max width
|
||||
fontSize: number;
|
||||
fitHorizontally: boolean;
|
||||
}
|
||||
|
||||
// Common terminal widths
|
||||
export const COMMON_TERMINAL_WIDTHS = [
|
||||
{ value: 0, label: '∞', description: 'Unlimited (full width)' },
|
||||
{ value: 80, label: '80', description: 'Classic terminal' },
|
||||
{ value: 100, label: '100', description: 'Modern standard' },
|
||||
{ value: 120, label: '120', description: 'Wide terminal' },
|
||||
{ value: 132, label: '132', description: 'Mainframe width' },
|
||||
{ value: 160, label: '160', description: 'Ultra-wide' },
|
||||
] as const;
|
||||
|
||||
const DEFAULT_PREFERENCES: TerminalPreferences = {
|
||||
maxCols: 0, // No limit by default - take as much as possible
|
||||
fontSize: 14,
|
||||
fitHorizontally: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY_TERMINAL_PREFS = 'vibetunnel_terminal_preferences';
|
||||
|
||||
export class TerminalPreferencesManager {
|
||||
private static instance: TerminalPreferencesManager;
|
||||
private preferences: TerminalPreferences;
|
||||
|
||||
private constructor() {
|
||||
this.preferences = this.loadPreferences();
|
||||
}
|
||||
|
||||
static getInstance(): TerminalPreferencesManager {
|
||||
if (!TerminalPreferencesManager.instance) {
|
||||
TerminalPreferencesManager.instance = new TerminalPreferencesManager();
|
||||
}
|
||||
return TerminalPreferencesManager.instance;
|
||||
}
|
||||
|
||||
private loadPreferences(): TerminalPreferences {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_TERMINAL_PREFS);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
// Merge with defaults to handle new properties
|
||||
return { ...DEFAULT_PREFERENCES, ...parsed };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load terminal preferences:', error);
|
||||
}
|
||||
return { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
|
||||
private savePreferences() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_TERMINAL_PREFS, JSON.stringify(this.preferences));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save terminal preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getMaxCols(): number {
|
||||
return this.preferences.maxCols;
|
||||
}
|
||||
|
||||
setMaxCols(maxCols: number) {
|
||||
this.preferences.maxCols = Math.max(0, maxCols); // Ensure non-negative
|
||||
this.savePreferences();
|
||||
}
|
||||
|
||||
getFontSize(): number {
|
||||
return this.preferences.fontSize;
|
||||
}
|
||||
|
||||
setFontSize(fontSize: number) {
|
||||
this.preferences.fontSize = Math.max(8, Math.min(32, fontSize)); // Reasonable bounds
|
||||
this.savePreferences();
|
||||
}
|
||||
|
||||
getFitHorizontally(): boolean {
|
||||
return this.preferences.fitHorizontally;
|
||||
}
|
||||
|
||||
setFitHorizontally(fitHorizontally: boolean) {
|
||||
this.preferences.fitHorizontally = fitHorizontally;
|
||||
this.savePreferences();
|
||||
}
|
||||
|
||||
getPreferences(): TerminalPreferences {
|
||||
return { ...this.preferences };
|
||||
}
|
||||
|
||||
resetToDefaults() {
|
||||
this.preferences = { ...DEFAULT_PREFERENCES };
|
||||
this.savePreferences();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue