mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: raghav-latte <131696988+raghav-latte@users.noreply.github.com>
This commit is contained in:
parent
48ea8898fa
commit
966c671755
4 changed files with 319 additions and 7 deletions
|
|
@ -112,6 +112,18 @@ async function build() {
|
||||||
// Build native executable
|
// Build native executable
|
||||||
console.log('Building native executable...');
|
console.log('Building native executable...');
|
||||||
|
|
||||||
|
// Check if native binaries already exist (skip build for development)
|
||||||
|
const nativeDir = path.join(__dirname, '..', 'native');
|
||||||
|
const vibetunnelPath = path.join(nativeDir, 'vibetunnel');
|
||||||
|
const ptyNodePath = path.join(nativeDir, 'pty.node');
|
||||||
|
const spawnHelperPath = path.join(nativeDir, 'spawn-helper');
|
||||||
|
|
||||||
|
if (fs.existsSync(vibetunnelPath) && fs.existsSync(ptyNodePath) && fs.existsSync(spawnHelperPath)) {
|
||||||
|
console.log('✅ Native binaries already exist, skipping build...');
|
||||||
|
console.log(' - vibetunnel executable: ✓');
|
||||||
|
console.log(' - pty.node: ✓');
|
||||||
|
console.log(' - spawn-helper: ✓');
|
||||||
|
} else {
|
||||||
// Check for --custom-node flag
|
// Check for --custom-node flag
|
||||||
const useCustomNode = process.argv.includes('--custom-node');
|
const useCustomNode = process.argv.includes('--custom-node');
|
||||||
|
|
||||||
|
|
@ -122,6 +134,7 @@ async function build() {
|
||||||
console.log('Using system Node.js...');
|
console.log('Using system Node.js...');
|
||||||
execSync('node build-native.js', { stdio: 'inherit' });
|
execSync('node build-native.js', { stdio: 'inherit' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Build completed successfully!');
|
console.log('Build completed successfully!');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
web/src/server/pty/fish-handler.ts
Normal file
106
web/src/server/pty/fish-handler.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* Fish Shell Handler
|
||||||
|
*
|
||||||
|
* Provides fish shell tab completion support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export class FishHandler {
|
||||||
|
/**
|
||||||
|
* Get completion suggestions for a partial command
|
||||||
|
*/
|
||||||
|
async getCompletions(partial: string, cwd: string = process.cwd()): Promise<string[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
// Use fish's built-in completion system with proper escaping
|
||||||
|
const fishProcess = spawn('fish', ['-c', `complete -C ${JSON.stringify(partial)}`], {
|
||||||
|
cwd,
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
fishProcess.kill('SIGTERM');
|
||||||
|
resolve([]);
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
fishProcess.stdout?.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
fishProcess.on('close', (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (code !== 0 || !stdout.trim()) {
|
||||||
|
resolve([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completions = stdout
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => line.split('\t')[0]) // Fish completions are tab-separated
|
||||||
|
.filter((completion) => completion && completion !== partial);
|
||||||
|
|
||||||
|
resolve(completions);
|
||||||
|
});
|
||||||
|
|
||||||
|
fishProcess.on('error', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve([]);
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
resolve([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current shell is fish
|
||||||
|
*/
|
||||||
|
static isFishShell(shellPath: string): boolean {
|
||||||
|
const basename = path.basename(shellPath);
|
||||||
|
// Exact match for fish or fish with version suffix (e.g., fish3)
|
||||||
|
return basename === 'fish' || /^fish\d*$/.test(basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fish shell version
|
||||||
|
*/
|
||||||
|
static async getFishVersion(): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const fishProcess = spawn('fish', ['--version'], {
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
fishProcess.kill('SIGTERM');
|
||||||
|
resolve(null);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
fishProcess.stdout?.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
fishProcess.on('close', (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(code === 0 && stdout.trim() ? stdout.trim() : null);
|
||||||
|
});
|
||||||
|
|
||||||
|
fishProcess.on('error', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const fishHandler = new FishHandler();
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
import { WriteQueue } from '../utils/write-queue.js';
|
import { WriteQueue } from '../utils/write-queue.js';
|
||||||
import { VERSION } from '../version.js';
|
import { VERSION } from '../version.js';
|
||||||
import { AsciinemaWriter } from './asciinema-writer.js';
|
import { AsciinemaWriter } from './asciinema-writer.js';
|
||||||
|
import { FishHandler } from './fish-handler.js';
|
||||||
import { ProcessUtils } from './process-utils.js';
|
import { ProcessUtils } from './process-utils.js';
|
||||||
import { SessionManager } from './session-manager.js';
|
import { SessionManager } from './session-manager.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -220,6 +221,9 @@ export class PtyManager extends EventEmitter {
|
||||||
const { command: finalCommand, args: finalArgs } = resolved;
|
const { command: finalCommand, args: finalArgs } = resolved;
|
||||||
const resolvedCommand = [finalCommand, ...finalArgs];
|
const resolvedCommand = [finalCommand, ...finalArgs];
|
||||||
|
|
||||||
|
// Note: Fish shell expansion was removed for simplicity
|
||||||
|
// Only basic tab completion is supported via getFishCompletions()
|
||||||
|
|
||||||
// Log resolution details
|
// Log resolution details
|
||||||
if (resolved.resolvedFrom === 'alias') {
|
if (resolved.resolvedFrom === 'alias') {
|
||||||
logger.log(
|
logger.log(
|
||||||
|
|
@ -884,6 +888,30 @@ export class PtyManager extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fish shell completions for a partial command
|
||||||
|
*/
|
||||||
|
async getFishCompletions(sessionId: string, partial: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const userShell = ProcessUtils.getUserShell();
|
||||||
|
if (!FishHandler.isFishShell(userShell)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fishHandler } = await import('./fish-handler.js');
|
||||||
|
const cwd = session.currentWorkingDir || process.cwd();
|
||||||
|
return await fishHandler.getCompletions(partial, cwd);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Fish completions failed: ${error}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send text input to a session
|
* Send text input to a session
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
165
web/src/test/server/pty/fish-handler.test.ts
Normal file
165
web/src/test/server/pty/fish-handler.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FishHandler } from '../../../server/pty/fish-handler.js';
|
||||||
|
|
||||||
|
// Mock child_process
|
||||||
|
vi.mock('child_process', () => ({
|
||||||
|
spawn: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
const mockSpawn = vi.mocked(spawn);
|
||||||
|
|
||||||
|
// Helper to create mock process
|
||||||
|
const createMockProcess = (stdout: string, exitCode: number = 0, shouldError = false) => {
|
||||||
|
const mockProcess = {
|
||||||
|
stdout: {
|
||||||
|
on: vi.fn((event, callback) => {
|
||||||
|
if (event === 'data' && !shouldError) {
|
||||||
|
callback(Buffer.from(stdout));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
on: vi.fn((event, callback) => {
|
||||||
|
if (event === 'close') {
|
||||||
|
setTimeout(() => callback(exitCode), 0);
|
||||||
|
} else if (event === 'error' && shouldError) {
|
||||||
|
setTimeout(() => callback(new Error('Process error')), 0);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
kill: vi.fn(),
|
||||||
|
};
|
||||||
|
return mockProcess;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('FishHandler', () => {
|
||||||
|
let fishHandler: FishHandler;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fishHandler = new FishHandler();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCompletions', () => {
|
||||||
|
it('should return empty array when fish command fails', async () => {
|
||||||
|
const mockProcess = createMockProcess('', 1);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const result = await fishHandler.getCompletions('ls');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when fish has no stdout', async () => {
|
||||||
|
const mockProcess = createMockProcess('', 0);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const result = await fishHandler.getCompletions('ls');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse fish completions correctly', async () => {
|
||||||
|
const mockProcess = createMockProcess(
|
||||||
|
'ls\t\nls-color\tColorized ls\nls-files\tList files only\n',
|
||||||
|
0
|
||||||
|
);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const result = await fishHandler.getCompletions('ls');
|
||||||
|
expect(result).toEqual(['ls-color', 'ls-files']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out the original partial command', async () => {
|
||||||
|
const mockProcess = createMockProcess(
|
||||||
|
'git\t\ngit-add\tAdd files\ngit-commit\tCommit changes\n',
|
||||||
|
0
|
||||||
|
);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const result = await fishHandler.getCompletions('git');
|
||||||
|
expect(result).toEqual(['git-add', 'git-commit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty completions gracefully', async () => {
|
||||||
|
const mockProcess = createMockProcess('\n\n\n', 0);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const result = await fishHandler.getCompletions('nonexistent');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle fish command timeout/errors', async () => {
|
||||||
|
const mockProcess = createMockProcess('', 0, true);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const result = await fishHandler.getCompletions('ls');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call fish with correct parameters', async () => {
|
||||||
|
const mockProcess = createMockProcess('test\n', 0);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
await fishHandler.getCompletions('ls /tmp', '/home/user');
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith('fish', ['-c', 'complete -C "ls /tmp"'], {
|
||||||
|
cwd: '/home/user',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use current working directory as default', async () => {
|
||||||
|
const mockProcess = createMockProcess('test\n', 0);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
await fishHandler.getCompletions('ls');
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledWith('fish', ['-c', 'complete -C "ls"'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isFishShell', () => {
|
||||||
|
it('should return true for fish shell paths', () => {
|
||||||
|
expect(FishHandler.isFishShell('/usr/bin/fish')).toBe(true);
|
||||||
|
expect(FishHandler.isFishShell('/opt/homebrew/bin/fish')).toBe(true);
|
||||||
|
expect(FishHandler.isFishShell('fish')).toBe(true);
|
||||||
|
expect(FishHandler.isFishShell('/usr/bin/fish3')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-fish shells', () => {
|
||||||
|
expect(FishHandler.isFishShell('/bin/bash')).toBe(false);
|
||||||
|
expect(FishHandler.isFishShell('/bin/zsh')).toBe(false);
|
||||||
|
expect(FishHandler.isFishShell('/bin/sh')).toBe(false);
|
||||||
|
expect(FishHandler.isFishShell('/usr/bin/catfish')).toBe(false);
|
||||||
|
expect(FishHandler.isFishShell('/usr/bin/fisherman')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFishVersion', () => {
|
||||||
|
it('should return version when fish is available', async () => {
|
||||||
|
const mockProcess = createMockProcess('fish, version 3.6.1', 0);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const version = await FishHandler.getFishVersion();
|
||||||
|
expect(version).toBe('fish, version 3.6.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when fish is not available', async () => {
|
||||||
|
const mockProcess = createMockProcess('', 1);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const version = await FishHandler.getFishVersion();
|
||||||
|
expect(version).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when fish command throws', async () => {
|
||||||
|
const mockProcess = createMockProcess('', 0, true);
|
||||||
|
mockSpawn.mockReturnValue(mockProcess);
|
||||||
|
|
||||||
|
const version = await FishHandler.getFishVersion();
|
||||||
|
expect(version).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue