This guide is for agentic coding agents working in the XTerm File Manager codebase.
XTerm File Manager is a modern SSH terminal with integrated file manager built with:
- Backend: Go 1.23+ with Wails v2.11.0 framework
- Frontend: React 18.2 + TypeScript 5.2 + Vite 5.0
- Key Libraries: xterm.js (terminal), Monaco Editor (code editor), Ant Design (UI), SFTP (file transfers)
# Run in development mode (auto-starts Vite dev server + Go backend)
wails dev
# Clean Vite cache if UI not updating
cd frontend && rm -rf node_modules/.vite .vite && cd ..
wails dev
# Full clean rebuild
rm -rf frontend/dist/assets frontend/dist/*.html frontend/dist/*.js
cd frontend && rm -rf node_modules/.vite .vite && npm install && cd ..
wails dev# IMPORTANT: Always clean caches before building
rm -rf build/bin/* frontend/dist/assets
cd frontend && rm -rf node_modules/.vite .vite && cd ..
# Build for specific platforms (always use -clean flag)
wails build -platform darwin/arm64 -clean # macOS Apple Silicon
wails build -platform darwin/amd64 -clean # macOS Intel
wails build -platform windows/amd64 -clean # Windows
wails build -platform linux/amd64 -clean # Linux
# Re-sign with entitlements to remove sandbox restrictions
./scripts/post-build-sign.sh
# Kill old process before opening new build (macOS reuses running instances)
pkill -f xterm-file-manager 2>/dev/null; sleep 1
open build/bin/xterm-file-manager.app
# One-liner for quick rebuild (macOS Apple Silicon):
rm -rf build/bin/* frontend/dist/assets && cd frontend && rm -rf node_modules/.vite .vite && cd .. && wails build -platform darwin/arm64 -clean && ./scripts/post-build-sign.sh && pkill -f xterm-file-manager 2>/dev/null; sleep 1; open build/bin/xterm-file-manager.appcd frontend
# Install dependencies
npm install
# Run Vite dev server standalone (port auto-assigned)
npm run dev
# Build frontend for production
npm run build
# Preview production build
npm run preview# Install dependencies
go mod download
# Run Go backend tests (currently no test files)
go test ./...
# Run tests for specific package
go test ./internal/app
# Run single test function
go test -run TestFunctionName ./internal/app
# Run with verbose output
go test -v ./...
# Check for Go issues
go vet ./...
# Format Go code
go fmt ./...# Wails auto-generates Go->JS bindings in frontend/wailsjs/go/app/
# These are regenerated automatically during wails dev or wails buildxterm-file-manager/
├── main.go # Application entry point
├── internal/app/ # Business logic (package app)
│ ├── app.go # App struct, settings, file operations
│ ├── ssh.go # SSH config parser
│ ├── ssh_manager.go # SSH connection pool
│ ├── websocket_handler.go # Terminal PTY I/O
│ ├── local_files.go # Local + SFTP file operations
│ ├── editor_server.go # HTTP server for editor
│ └── editor_window_darwin.go # Native macOS windows (CGo)
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── terminal/ # Terminal components
│ │ │ ├── file-manager/ # File manager components
│ │ │ ├── editor/ # Code editor
│ │ │ ├── session/ # Session management
│ │ │ └── tools/ # Utility tools
│ │ ├── utils/ # Utilities (logger, etc.)
│ │ └── main.tsx # React entry point
│ ├── wailsjs/ # Auto-generated Wails bindings
│ │ └── go/app/ # Go -> TypeScript bindings
│ ├── tsconfig.json # TypeScript config
│ └── package.json # NPM config
├── build/ # Build resources (icons, manifests)
├── docs/ # Documentation
├── scripts/ # Helper scripts
├── go.mod # Go dependencies
└── wails.json # Wails configuration
Imports:
package app
import (
// Standard library first
"context"
"fmt"
"log"
"os"
// Third-party packages second (alphabetically)
"github.com/creack/pty"
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/crypto/ssh"
)Naming Conventions:
- Exported functions:
PascalCase(e.g.,GetSSHConfig,WriteLocalFile) - Unexported functions:
camelCase(e.g.,getSettingsPath,dirExists) - Struct fields:
PascalCase(exported),camelCase(unexported) - Constants:
PascalCase(e.g.,IOBufferSize,SSHConnectTimeout)
Error Handling:
// Wrap errors with context
if err != nil {
return fmt.Errorf("failed to read file: %v", err)
}
// Check specific error types
if os.IsNotExist(err) {
// handle missing file
}
// Use io.EOF for end-of-stream (not string comparison)
if err == io.EOF {
break
}Logging:
// Use log.Printf with emoji prefixes for visibility
log.Printf("⚠️ Failed to start editor server: %v", err)
log.Printf("🔐 New host key for %s (SHA256:%s)", host, fpStr)Concurrency:
// Use sync.RWMutex for shared state
type SSHSession struct {
ID string
mu sync.RWMutex
}
func (s *SSHSession) GetStatus() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.status
}Constants:
// Extract magic numbers to named constants
const (
IOBufferSize = 32 * 1024 // 32KB buffer for I/O
SSHConnectTimeout = 10 // seconds
MaxUntitledFiles = 1000
)Imports:
// React first
import React, { useState, useEffect, useCallback, useRef } from 'react'
// Third-party libraries
import { Terminal as XTerm } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { Button, Modal } from 'antd'
// Wails bindings
import { WriteToTerminal, StartTerminalSession } from '../../../wailsjs/go/app/App'
import { EventsOn } from '../../../wailsjs/runtime/runtime'
// Local utilities
import logger from '../../utils/logger'
// CSS last
import './Terminal.css'Naming Conventions:
- Components:
PascalCase(e.g.,Terminal,FileManager) - Props interfaces:
{ComponentName}Props(e.g.,TerminalProps) - Functions:
camelCase(e.g.,handleResize,loadFiles) - Event handlers:
handle{Event}(e.g.,handleClick,handleKeyDown) - Constants:
UPPER_SNAKE_CASEorcamelCasefor local
Component Structure:
interface TerminalProps {
sessionId: string
sessionType: 'ssh' | 'local'
isActive: boolean
}
const Terminal: React.FC<TerminalProps> = ({
sessionId,
sessionType,
isActive,
}) => {
// State hooks first
const [loading, setLoading] = useState(false)
// Refs second
const terminalRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<XTerm | null>(null)
// Callbacks third
const handleResize = useCallback(() => {
// implementation
}, [sessionId])
// Effects last
useEffect(() => {
// setup
return () => {
// cleanup
}
}, [dependencies])
return <div ref={terminalRef} />
}
export default TerminalTypes:
- Use explicit types for props and state
- Prefer
interfaceovertypefor object shapes - Use TypeScript's strict mode (
strict: truein tsconfig.json)
Error Handling:
// Always catch promise rejections
WriteToTerminal(sessionId, data).catch((err) => {
console.error('Failed to write to terminal:', err)
})
// Use try-catch for async/await
try {
const result = await StartTerminalSession(sessionId, rows, cols)
} catch (error) {
console.error('Failed to start session:', error)
}Logging:
// Use logger utility with emoji prefixes
import logger from '../../utils/logger'
logger.log('🎯 [Terminal] Installing custom key handler')
logger.log('✅ [Terminal] Cmd+C detected, copying selection')
logger.log('⚠️ [Terminal] No selection, sending interrupt')
logger.log('❌ [Terminal] Failed to copy:', err)React Patterns:
- Use functional components with hooks (no class components)
- Use
useRefto store values that don't trigger re-renders - Use
useCallbackfor event handlers passed to child components - Clean up effects (timers, listeners, observers) in return function
- Prevent duplicate effect execution with guard refs (see Terminal.tsx:sessionStartedRef)
- File Paths: Always handle
~expansion in Go backend (useos.UserHomeDir()) - Async Operations: Always handle errors for Go backend calls from React
- Terminal I/O: Use
IOBufferSizeconstant (32KB) for buffer operations - SSH Security: Implement TOFU (Trust On First Use) for host key verification
- Resource Management: Always close SFTP clients, file handles, WebSocket connections
- React StrictMode: Guard against duplicate effect calls using refs
- Emoji Logging: Use emoji prefixes for better log visibility (
⚠️ 🔐 ✅ ❌ 🎯 📥) - SSH Config Auto-Reload: When
~/.ssh/configis saved (via WriteLocalFile/WriteRemoteFile),ssh:config-changedevent triggers frontend to reload SSH config list immediately (app.go:258, TerminalTab.tsx:79, SyncPanel.tsx:128) 8.1. Command Snippets Auto-Reload: The SSH-only Commands pane reads snippets from~/Library/Application Support/xterm-file-manager/command-snippets.json(viaos.UserConfigDir()on each platform). When that file is saved throughWriteLocalFile(), backend emitscommand-snippets:changedsoCommandPanel.tsxreloads immediately without restart. - Tab Drag and Drop: Both Terminal tabs and Editor tabs support HTML5 native drag-and-drop for reordering. Implementation uses
draggable={true},onDragStart,onDragOver, andonDragEndhandlers with real-time array reordering and visual feedback (opacity 0.5 during drag, grab/grabbing cursors) - Session Persistence: Terminal sessions and Editor tabs are auto-saved to
~/Library/Application Support/xterm-file-manager/sessions.jsonandeditor-tabs.json. Sessions restore on app startup with auto-reconnection for SSH and local terminals. Editor tabs restore with error handling for missing files. - Wails WKWebView Drop Event Limitation (CRITICAL): When
DragAndDrop.DisableWebViewDrop: trueis set inmain.go, the native WKWebView on macOS intercepts ALL drop operations at the Objective-C level (WailsWebView.m:performDragOperation). This means JavaScriptdropevents NEVER fire — not for OS-level drags, and not for in-app HTML5 drags. Onlydragendfires. The workaround is:- Use a shared memory module (
frontend/src/utils/dragState.ts) instead ofdataTransfer.getData()to pass drag payloads between components - Use
dragendonwindow(capture phase) instead ofdropto detect when the user releases the mouse - Use
document.elementFromPoint()duringdragoverto track which zone (terminal-pane / local-file-manager / file-manager-container) the cursor is over - Store the target zone in
dragState.setDragTarget()and read it in thedragendhandler - File managers must also use
dragend(via window listener) to clear theirdragOvervisual state, sincedrop(which normally clears it) never fires
- Use a shared memory module (
- ConnectSSH Parameter:
ConnectSSH()in Go backend accepts a fullSSHConfigEntryobject, NOT a host string. Always pass the complete config object fromsshConfigsstate. - Frontend Debug Log Tab: The app includes a built-in Log tab (
frontend/src/components/log/LogTab.tsx) for debugging frontend issues without browser DevTools. Since Wails WKWebView doesn't expose DevTools in production, this is the primary debugging tool.- Import
dlogfromfrontend/src/utils/debugLog.tsand calldlog('message')to log messages - Logs are stored in memory (max 500 entries) and displayed in the Log tab in the main UI
- Log tab has Copy All button to copy all logs for pasting into bug reports
dlog()also mirrors toconsole.logfor development mode- All drag-and-drop related code uses
dlog()for traceability
- Import
- System Clipboard File Copy: All three file managers (FileManager, LocalFileManager, FileBrowserPanel) support "Copy to System Clipboard" via right-click context menu. This copies files to the OS pasteboard so users can paste into external apps (Finder, Feishu, WeChat, etc.).
- macOS: Uses
NSPasteboard writeObjects:withNSURL fileURLWithPath:via CGo (clipboard_darwin.go) - Windows: Uses PowerShell
Set-Clipboard -Pathto set CF_HDROP format (clipboard_windows.go) - Linux: Stub returns "not supported" (
clipboard_stub.go) - Local files: Direct clipboard write via
CopyFilesToSystemClipboard(paths) - Remote files: Two-step: download to temp dir via SFTP, then clipboard write via
CopyRemoteFilesToSystemClipboard(sessionID, remotePaths) - Temp cleanup: Temp directories tracked in
tempDirsslice, cleaned on app shutdown viaCleanupTempDirs()called fromOnShutdowninmain.go - Distinction from in-app clipboard:
SetFileClipboard/PasteFilesis the app-internal clipboard (memory-only, for Copy/Cut/Paste within the app).CopyFilesToSystemClipboardwrites to the OS pasteboard for cross-app sharing.
- macOS: Uses
- SSH Auth Prompt Flow: Password retry and encrypted private-key retry now share the same frontend/backend path. Frontend should use
ConnectSSHWithAuth(config, password, passwordHost, keyPassphrase, keyIdentityFile)for prompt retries and parse machine-readable error prefixes:SSH_PASSWORD_REQUIRED:/SSH_PASSWORD_INVALID:SSH_KEY_PASSPHRASE_REQUIRED:/SSH_KEY_PASSPHRASE_INVALID:- On macOS, cached SSH passwords and key passphrases live in Keychain (
ssh_secret_store_darwin.go), not in plaintext app settings.
- Resolved SSH Config + Agent Forwarding: SSH connections must be built from resolved OpenSSH config (
ssh_config_resolver.go), not from the shallowSSHConfigEntryalone. Terminal sessions should enable agent forwarding only whensession.ResolvedConfig.ForwardAgentis true andsession.AgentHandleexists, usingagent.ForwardToAgent()beforeagent.RequestAgentForwarding().
// In internal/app/app.go
func (a *App) MyNewMethod(param string) (string, error) {
// implementation
return result, nil
}
// Automatically available in frontend as: MyNewMethod(param)import { MyNewMethod } from '../../../wailsjs/go/app/App'
const result = await MyNewMethod(param)// Backend
runtime.EventsEmit(a.ctx, "terminal:output", payload)
// Frontend
EventsOn('terminal:output', (payload) => {
// handle event
})Both Terminal tabs and Editor tabs support HTML5 native drag-and-drop for reordering tabs. This feature provides an intuitive way to organize multiple open sessions or files.
Key Features:
- HTML5 native Drag and Drop API (zero dependencies)
- Real-time visual feedback during drag (opacity 0.5, grab/grabbing cursors)
- Smooth array reordering with live preview
- Prevents text selection during drag with
user-select: none - Hover animation with
transform: translateY(-2px)
File: frontend/src/components/terminal/TerminalTab.tsx
// State for tracking drag operation
const [draggedTabIndex, setDraggedTabIndex] = useState<number | null>(null)
// Drag start handler
const handleTabDragStart = useCallback((e: React.DragEvent, index: number) => {
setDraggedTabIndex(index)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', index.toString())
// Visual feedback: semi-transparent during drag
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = '0.5'
}
}, [])
// Drag over handler: real-time reordering
const handleTabDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
if (draggedTabIndex === null || draggedTabIndex === index) return
// Reorder sessions array immediately for live preview
const newSessions = [...sessions]
const [draggedSession] = newSessions.splice(draggedTabIndex, 1)
newSessions.splice(index, 0, draggedSession)
setSessions(newSessions)
setDraggedTabIndex(index) // Update dragged index
}, [draggedTabIndex, sessions])
// Drag end handler: cleanup
const handleTabDragEnd = useCallback((e: React.DragEvent) => {
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = '1'
}
setDraggedTabIndex(null)
}, [])
// JSX: Add drag attributes to each tab
{sessions.map((session, index) => (
<div
key={session.id}
className={`session-tab ${activeSessionId === session.id ? 'active' : ''}`}
draggable={true}
onDragStart={(e) => handleTabDragStart(e, index)}
onDragOver={(e) => handleTabDragOver(e, index)}
onDragEnd={handleTabDragEnd}
onClick={() => setActiveSessionId(session.id)}
>
{/* Tab content */}
</div>
))}CSS (TerminalTab.css):
.session-tabs {
user-select: none; /* Prevent text selection during drag */
}
.session-tab {
cursor: pointer;
transition: background 0.2s, transform 0.2s;
}
.session-tab[draggable="true"] {
cursor: grab;
}
.session-tab[draggable="true"]:active {
cursor: grabbing;
}
.session-tab:hover {
background: #303030;
transform: translateY(-2px); /* Subtle lift effect */
}File: frontend/src/components/editor/EditorTab.tsx
Implementation is nearly identical to Terminal tabs, with the following differences:
- State: Uses
filesarray instead ofsessions - First Tab Sticky Fix: Temporarily disables
position: stickyduring drag to allow smooth reordering
/* Pin first tab so close-button stays at fixed screen position */
.custom-tab:first-child {
position: sticky;
left: 0;
z-index: 10;
}
/* Temporarily disable sticky during drag */
.custom-tab:first-child:active {
position: relative;
}- Why
indexinstead ofid: Array indices are used for reordering logic because they represent the tab's visual position - Real-time Updates:
setSessions/setFilesis called immediately inonDragOverfor live preview - Drag Index Update: After reordering,
setDraggedTabIndex(index)updates the tracked index to the new position - Opacity Reset:
onDragEndrestores opacity to 1 and clears drag state - Event Propagation:
e.stopPropagation()prevents drag events from bubbling to parent file drag handlers
- ✅ Drag first tab to last position
- ✅ Drag last tab to first position
- ✅ Drag middle tab to any position
- ✅ Fast consecutive drags
- ✅ Drag active tab (maintains focus)
- ✅ Single tab (no drag allowed by nature)
- ✅ Multiple tabs with scroll (tabs reorder correctly)
- Chrome/Edge: Full support
- Firefox: Full support
- Safari: Full support
- Touch devices: Requires additional
touch-actionCSS for mobile support (not implemented)
- Do NOT delete
frontend/dist/directory - containsgitkeepneeded by Go's//go:embed - PTY Lifecycle: Use
StartTerminalSession, NOT deprecatedCreatePTY(goroutine leak risk) - SFTP Pooling: SFTP clients are pooled - don't close them in file operation functions
- Wails Auto-generation: Never manually edit files in
frontend/wailsjs/- regenerated by Wails - Debug Logs: Platform-specific paths: macOS
~/Library/Logs/xterm-file-manager/debug.log, Linux~/.cache/xterm-file-manager/debug.log
Problem: When running wails dev, Vite automatically opens a browser tab at http://localhost:5173/, which is unnecessary since the Wails app window is the intended interface.
Solution: In frontend/vite.config.ts, the server.open option MUST be set to false:
// frontend/vite.config.ts
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
open: false, // CRITICAL: Prevent auto-opening browser in wails dev mode
},
// ... rest of config
})Why this matters: Without this setting, every wails dev session wastes user attention by opening an unwanted browser tab.
Problem: Local terminal sessions had broken Chinese input and Delete key behavior, while SSH terminals worked correctly.
Root Cause: Local terminals were missing proper TERM type and UTF-8 locale environment variables that SSH terminals automatically receive.
Solution: In internal/app/pty_unix.go, the following environment variables MUST be set:
// internal/app/pty_unix.go (StartLocalTerminalSession function)
// Set environment variables
cleanEnv := make([]string, 0, len(os.Environ()))
termSet := false
for _, env := range os.Environ() {
if len(env) >= 11 && env[:11] == "VIRTUAL_ENV" {
continue // skip VIRTUAL_ENV
}
if len(env) >= 5 && env[:5] == "TERM=" {
termSet = true
}
cleanEnv = append(cleanEnv, env)
}
// CRITICAL: Set TERM to xterm-256color if not already set
// This fixes Delete key and Chinese input issues
if !termSet {
cleanEnv = append(cleanEnv, "TERM=xterm-256color")
}
// CRITICAL: Set UTF-8 locale for proper Chinese character support
cleanEnv = append(cleanEnv, "LANG=en_US.UTF-8")
cleanEnv = append(cleanEnv, "LC_ALL=en_US.UTF-8")
cmd.Env = cleanEnvKey Points:
TERM=xterm-256color: Matches SSH terminal configuration (seeterminal_handler.go:72), enables proper terminal control sequencesLANG=en_US.UTF-8andLC_ALL=en_US.UTF-8: Enable UTF-8 multi-byte character support for Chinese/Japanese/Korean input- Without these: Delete key shows
^?or^H, Chinese characters become garbled - With these: Local terminal behaves identically to SSH terminal
Testing:
- Open local terminal
- Test Delete/Backspace key - should delete characters normally
- Input Chinese characters - should display correctly
- Compare with SSH terminal - should behave identically
File Manager Default Behavior (MUST SHOW HIDDEN FILES)
Problem: File managers were showing "Empty Directory" on servers where only hidden files exist (e.g., servers with only dotfiles like .bashrc, .ssh, .config in /root).
Root Cause: The showHidden state defaulted to false, filtering out all files starting with . from display, even though the SFTP connection and file listing worked correctly.
Solution: Both file managers MUST default to showing hidden files:
// frontend/src/components/file-manager/FileManager.tsx:60
const [showHidden, setShowHidden] = useState(true); // MUST be true
// frontend/src/components/file-manager/LocalFileManager.tsx:53
const [showHidden, setShowHidden] = useState(true); // MUST be trueWhy this matters:
- Many Linux servers store configuration and important files as hidden files (dotfiles)
- Without showing hidden files by default, users may think SFTP connection failed when it's actually working
- Matches behavior of most professional file managers (VS Code, FileZilla, etc.) which show all files by default
Affected Code:
- Remote file filtering:
FileManager.tsx:441-443 - Local file filtering:
LocalFileManager.tsx:384-386
const filteredFiles = showHidden
? files
: files.filter(f => !f.name.startsWith('.'));Testing:
- Connect to SSH server with only dotfiles in home directory
- Open file manager - should display all files including
.bashrc,.ssh, etc. - Verify both remote and local file managers show hidden files