mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Fix control message loop and simplify welcome screen repository display (#372)
This commit is contained in:
parent
500c75ebc8
commit
2f3a4217d0
8 changed files with 211 additions and 114 deletions
13
README.md
13
README.md
|
|
@ -675,6 +675,19 @@ When developing the web interface, you often need to test changes on external de
|
|||
- **Firewall**: macOS may prompt to allow incoming connections - click "Allow"
|
||||
- **Auto-rebuild**: Changes to the web code are automatically rebuilt, but you need to manually refresh the browser
|
||||
|
||||
##### Pasting on Mobile Devices
|
||||
|
||||
When using VibeTunnel on mobile browsers (Safari, Chrome), pasting works differently than on desktop:
|
||||
|
||||
**To paste on mobile:**
|
||||
1. Press the paste button on the keyboard toolbar
|
||||
2. A white input box will appear
|
||||
3. Long-press inside the white box to bring up the paste menu
|
||||
4. Select "Paste" from the menu
|
||||
5. The text will be pasted into your terminal session
|
||||
|
||||
**Note**: Due to browser security restrictions on non-HTTPS connections, the paste API is limited on mobile devices. The white input box is a workaround that allows clipboard access through the browser's native paste functionality.
|
||||
|
||||
#### Future: Hot Module Replacement
|
||||
|
||||
For true hot module replacement without manual refresh, see our [Vite migration plan](docs/vite-plan.md) which would provide:
|
||||
|
|
|
|||
|
|
@ -52,14 +52,20 @@ struct ControlAgentArmyPageView: View {
|
|||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
Text(
|
||||
"Session titles appear in the menu bar and terminal windows.\nUse the dashboard to rename sessions manually, or use the magic wand with Claude/Gemini."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 420)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
VStack(spacing: 8) {
|
||||
Text(
|
||||
"Session titles appear in the menu bar and terminal windows.\nUse the dashboard to rename sessions manually, or use the magic wand with Claude/Gemini."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 420)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Link("Learn more", destination: URL(string: "https://steipete.me/posts/command-your-claude-code-army-reloaded")!)
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,122 +20,85 @@ struct ProjectFolderPageView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
// Title and description
|
||||
VStack(spacing: 12) {
|
||||
VStack(spacing: 30) {
|
||||
VStack(spacing: 16) {
|
||||
Text("Choose Your Project Folder")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text(
|
||||
"Select the folder where you keep your projects. VibeTunnel will use this for quick access and repository discovery."
|
||||
)
|
||||
.font(.system(size: 14))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 400)
|
||||
}
|
||||
.frame(maxWidth: 480)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Folder picker section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Project Folder")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
// Folder and repository section
|
||||
VStack(spacing: 16) {
|
||||
// Folder picker
|
||||
HStack {
|
||||
Text(selectedPath.isEmpty ? "~/" : selectedPath)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(selectedPath.isEmpty ? .secondary : .primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
|
||||
HStack {
|
||||
Text(selectedPath.isEmpty ? "~/" : selectedPath)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(selectedPath.isEmpty ? .secondary : .primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
|
||||
Button("Choose...") {
|
||||
showFolderPicker()
|
||||
Button("Choose...") {
|
||||
showFolderPicker()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(width: 350)
|
||||
|
||||
// Repository preview
|
||||
if !selectedPath.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Repository count
|
||||
if !selectedPath.isEmpty {
|
||||
HStack {
|
||||
Text("Discovered Repositories")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
Image(systemName: "folder.badge.gearshape")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
|
||||
if isScanning {
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
.frame(width: 16, height: 16)
|
||||
Text("Scanning...")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
} else if discoveredRepos.isEmpty {
|
||||
Text("No repositories found")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("\(discoveredRepos.count) repositor\(discoveredRepos.count == 1 ? "y" : "ies") found")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if discoveredRepos.isEmpty && !isScanning {
|
||||
Text("No repositories found in this folder")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
ForEach(discoveredRepos) { repo in
|
||||
HStack {
|
||||
Image(systemName: "folder.badge.gearshape")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(repo.name)
|
||||
.font(.system(size: 11))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 100)
|
||||
.padding(8)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
|
||||
.cornerRadius(6)
|
||||
.frame(width: 350)
|
||||
}
|
||||
// Tip
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text("You can change this later in Settings → Application → Repository")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: 350)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 400)
|
||||
|
||||
// Tips
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "lightbulb")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.orange)
|
||||
|
||||
Text("You can change this later in Settings → Application → Repository Base Path")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Text("VibeTunnel will scan up to 3 levels deep for Git repositories")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 400)
|
||||
.padding(.top, 12)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
.padding()
|
||||
.onAppear {
|
||||
selectedPath = repositoryBasePath
|
||||
if !selectedPath.isEmpty {
|
||||
|
|
@ -190,7 +153,7 @@ struct ProjectFolderPageView: View {
|
|||
let repos = await findGitRepositories(in: expandedPath, maxDepth: 3)
|
||||
|
||||
await MainActor.run {
|
||||
discoveredRepos = repos.prefix(10).map { path in
|
||||
discoveredRepos = repos.map { path in
|
||||
RepositoryInfo(name: URL(fileURLWithPath: path).lastPathComponent, path: path)
|
||||
}
|
||||
isScanning = false
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ struct RequestPermissionsPageView: View {
|
|||
.foregroundColor(.secondary)
|
||||
}
|
||||
.font(.body)
|
||||
.frame(maxWidth: 250)
|
||||
.frame(maxWidth: 250, alignment: .leading)
|
||||
.frame(height: 32)
|
||||
} else {
|
||||
Button("Grant Automation Permission") {
|
||||
|
|
@ -93,7 +93,7 @@ struct RequestPermissionsPageView: View {
|
|||
.foregroundColor(.secondary)
|
||||
}
|
||||
.font(.body)
|
||||
.frame(maxWidth: 250)
|
||||
.frame(maxWidth: 250, alignment: .leading)
|
||||
.frame(height: 32)
|
||||
} else {
|
||||
Button("Grant Accessibility Permission") {
|
||||
|
|
@ -113,7 +113,7 @@ struct RequestPermissionsPageView: View {
|
|||
.foregroundColor(.secondary)
|
||||
}
|
||||
.font(.body)
|
||||
.frame(maxWidth: 250)
|
||||
.frame(maxWidth: 250, alignment: .leading)
|
||||
.frame(height: 32)
|
||||
} else {
|
||||
Button("Grant Screen Recording Permission") {
|
||||
|
|
|
|||
|
|
@ -1336,7 +1336,7 @@ export class ScreencapView extends LitElement {
|
|||
|
||||
// Show overlay when not capturing or waiting to start
|
||||
return html`
|
||||
<div class.capture-overlay>
|
||||
<div class="capture-overlay">
|
||||
<div class="status-message ${this.status}">
|
||||
${
|
||||
this.status === 'loading'
|
||||
|
|
|
|||
|
|
@ -617,10 +617,10 @@ export class UnifiedSettings extends LitElement {
|
|||
</div>
|
||||
|
||||
<!-- Repository Base Path -->
|
||||
<div class="p-4 bg-dark-bg-tertiary rounded-lg border border-dark-border">
|
||||
<div class="p-4 bg-tertiary rounded-lg border border-base">
|
||||
<div class="mb-3">
|
||||
<label class="text-dark-text font-medium">Repository Base Path</label>
|
||||
<p class="text-dark-text-muted text-xs mt-1">
|
||||
<label class="text-primary font-medium">Repository Base Path</label>
|
||||
<p class="text-muted text-xs mt-1">
|
||||
${
|
||||
this.isServerConfigured
|
||||
? 'This path is synced with the VibeTunnel Mac app'
|
||||
|
|
@ -646,7 +646,7 @@ export class UnifiedSettings extends LitElement {
|
|||
${
|
||||
this.isServerConfigured
|
||||
? html`
|
||||
<div class="flex items-center text-dark-text-muted" title="Synced with Mac app">
|
||||
<div class="flex items-center text-muted" title="Synced with Mac app">
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
|
|
|
|||
|
|
@ -82,23 +82,28 @@ class SystemHandler implements MessageHandler {
|
|||
constructor(private controlUnixHandler: ControlUnixHandler) {}
|
||||
|
||||
async handleMessage(message: ControlMessage): Promise<ControlMessage | null> {
|
||||
logger.log(`System handler: ${message.action}`);
|
||||
logger.log(`System handler: ${message.action}, type: ${message.type}, id: ${message.id}`);
|
||||
|
||||
switch (message.action) {
|
||||
case 'repository-path-update': {
|
||||
const payload = message.payload as { path: string };
|
||||
logger.log(`Repository path update received: ${JSON.stringify(payload)}`);
|
||||
|
||||
if (!payload?.path) {
|
||||
logger.error('Missing path in payload');
|
||||
return createControlResponse(message, null, 'Missing path in payload');
|
||||
}
|
||||
|
||||
try {
|
||||
// Update the server configuration
|
||||
logger.log(`Calling updateRepositoryPath with: ${payload.path}`);
|
||||
const updateSuccess = await this.controlUnixHandler.updateRepositoryPath(payload.path);
|
||||
|
||||
if (updateSuccess) {
|
||||
logger.log(`Updated repository path to: ${payload.path}`);
|
||||
logger.log(`Successfully updated repository path to: ${payload.path}`);
|
||||
return createControlResponse(message, { success: true, path: payload.path });
|
||||
} else {
|
||||
logger.error('updateRepositoryPath returned false');
|
||||
return createControlResponse(message, null, 'Failed to update repository path');
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -566,7 +571,7 @@ export class ControlUnixHandler {
|
|||
|
||||
private async handleMacMessage(message: ControlMessage) {
|
||||
logger.log(
|
||||
`Mac message - category: ${message.category}, action: ${message.action}, id: ${message.id}`
|
||||
`Mac message - category: ${message.category}, action: ${message.action}, type: ${message.type}, id: ${message.id}`
|
||||
);
|
||||
|
||||
// Handle ping keep-alive from Mac client
|
||||
|
|
@ -576,6 +581,11 @@ export class ControlUnixHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
// Log repository-path-update messages specifically
|
||||
if (message.category === 'system' && message.action === 'repository-path-update') {
|
||||
logger.log(`🔍 Repository path update message details:`, JSON.stringify(message));
|
||||
}
|
||||
|
||||
// Check if this is a response to a pending request
|
||||
if (message.type === 'response' && this.pendingRequests.has(message.id)) {
|
||||
const resolver = this.pendingRequests.get(message.id);
|
||||
|
|
@ -587,6 +597,15 @@ export class ControlUnixHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
// Skip processing for response messages that aren't pending requests
|
||||
// This prevents response loops where error responses get processed again
|
||||
if (message.type === 'response') {
|
||||
logger.debug(
|
||||
`Ignoring response message that has no pending request: ${message.id}, action: ${message.action}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = this.handlers.get(message.category);
|
||||
if (!handler) {
|
||||
logger.warn(`No handler for category: ${message.category}`);
|
||||
|
|
@ -722,16 +741,21 @@ export class ControlUnixHandler {
|
|||
* Update the repository path and notify all connected clients
|
||||
*/
|
||||
async updateRepositoryPath(path: string): Promise<boolean> {
|
||||
logger.log(`updateRepositoryPath called with path: ${path}`);
|
||||
|
||||
try {
|
||||
this.currentRepositoryPath = path;
|
||||
logger.log(`Set currentRepositoryPath to: ${this.currentRepositoryPath}`);
|
||||
|
||||
// Call the callback to update server configuration and broadcast to web clients
|
||||
if (this.configUpdateCallback) {
|
||||
logger.log('Calling configUpdateCallback...');
|
||||
this.configUpdateCallback({ repositoryBasePath: path });
|
||||
logger.log('configUpdateCallback completed successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.warn('No config update callback set');
|
||||
logger.warn('No config update callback set - is the server initialized?');
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update repository path:', error);
|
||||
|
|
|
|||
|
|
@ -91,4 +91,95 @@ describe('Control Unix Handler', () => {
|
|||
expect(mockCallback).toHaveBeenCalledWith({ repositoryBasePath: '/test/path' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mac Message Handling', () => {
|
||||
it('should process repository-path-update messages from Mac app', async () => {
|
||||
const mockCallback = vi.fn();
|
||||
controlUnixHandler.setConfigUpdateCallback(mockCallback);
|
||||
|
||||
// Simulate Mac sending a repository-path-update message
|
||||
const message = {
|
||||
id: 'mac-msg-123',
|
||||
type: 'request' as const,
|
||||
category: 'system' as const,
|
||||
action: 'repository-path-update',
|
||||
payload: { path: '/Users/test/MacSelectedPath' },
|
||||
};
|
||||
|
||||
// Process the message through the system handler
|
||||
const systemHandler = (controlUnixHandler as any).handlers.get('system');
|
||||
const response = await systemHandler.handleMessage(message);
|
||||
|
||||
// Verify the update was processed
|
||||
expect(mockCallback).toHaveBeenCalledWith({
|
||||
repositoryBasePath: '/Users/test/MacSelectedPath',
|
||||
});
|
||||
|
||||
// Verify successful response
|
||||
expect(response).toMatchObject({
|
||||
id: 'mac-msg-123',
|
||||
type: 'response',
|
||||
category: 'system',
|
||||
action: 'repository-path-update',
|
||||
payload: { success: true, path: '/Users/test/MacSelectedPath' },
|
||||
});
|
||||
|
||||
// Verify the path was stored
|
||||
expect(controlUnixHandler.getRepositoryPath()).toBe('/Users/test/MacSelectedPath');
|
||||
});
|
||||
|
||||
it('should handle missing path in repository-path-update payload', async () => {
|
||||
const mockCallback = vi.fn();
|
||||
controlUnixHandler.setConfigUpdateCallback(mockCallback);
|
||||
|
||||
// Message with missing path
|
||||
const message = {
|
||||
id: 'mac-msg-456',
|
||||
type: 'request' as const,
|
||||
category: 'system' as const,
|
||||
action: 'repository-path-update',
|
||||
payload: {},
|
||||
};
|
||||
|
||||
// Process the message
|
||||
const systemHandler = (controlUnixHandler as any).handlers.get('system');
|
||||
const response = await systemHandler.handleMessage(message);
|
||||
|
||||
// Verify callback was not called
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
|
||||
// Verify error response
|
||||
expect(response).toMatchObject({
|
||||
id: 'mac-msg-456',
|
||||
type: 'response',
|
||||
category: 'system',
|
||||
action: 'repository-path-update',
|
||||
error: 'Missing path in payload',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not process response messages for repository-path-update', async () => {
|
||||
const mockCallback = vi.fn();
|
||||
controlUnixHandler.setConfigUpdateCallback(mockCallback);
|
||||
|
||||
// Response message (should be ignored)
|
||||
const message = {
|
||||
id: 'mac-msg-789',
|
||||
type: 'response' as const,
|
||||
category: 'system' as const,
|
||||
action: 'repository-path-update',
|
||||
payload: { success: true, path: '/some/path' },
|
||||
};
|
||||
|
||||
// Simulate handleMacMessage behavior - response messages without pending requests are ignored
|
||||
const pendingRequests = (controlUnixHandler as any).pendingRequests;
|
||||
const hasPendingRequest = pendingRequests.has(message.id);
|
||||
|
||||
// Since this is a response without a pending request, it should be ignored
|
||||
expect(hasPendingRequest).toBe(false);
|
||||
|
||||
// Verify callback was not called
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue