vibetunnel/tauri/src/menubar.html

877 lines
No EOL
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VibeTunnel Menu</title>
<style>
:root {
--menu-width: 360px;
--header-gradient-start: #f8f9fa;
--header-gradient-end: #e9ecef;
--accent-color: #007aff;
--accent-hover: #0051d5;
--destructive-color: #ff3b30;
--success-color: #34c759;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--border-color: #d2d2d7;
--hover-bg: rgba(0, 122, 255, 0.08);
--focus-ring: rgba(0, 122, 255, 0.5);
--activity-color: #30d158;
--git-clean: #34c759;
--git-modified: #ff9500;
}
@media (prefers-color-scheme: dark) {
:root {
--header-gradient-start: #2c2c2e;
--header-gradient-end: #1c1c1e;
--text-primary: #f2f2f7;
--text-secondary: #98989f;
--border-color: #38383a;
--hover-bg: rgba(0, 122, 255, 0.15);
--bg-primary: #1c1c1e;
--bg-secondary: #2c2c2e;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
background: var(--bg-primary, white);
color: var(--text-primary);
user-select: none;
overflow: hidden;
margin: 0;
padding: 0;
}
.menu-container {
width: var(--menu-width);
background: var(--bg-primary, white);
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 100vh;
}
/* Header Section */
.header {
background: linear-gradient(to bottom, var(--header-gradient-start), var(--header-gradient-end));
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
}
.header-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.app-info {
display: flex;
align-items: center;
gap: 8px;
}
.app-icon {
width: 24px;
height: 24px;
border-radius: 4px;
}
.app-title {
font-size: 14px;
font-weight: 600;
}
.status-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 500;
transition: all 0.15s ease;
}
.status-badge.running {
background: rgba(52, 199, 89, 0.1);
color: var(--success-color);
border: 0.5px solid rgba(52, 199, 89, 0.3);
}
.status-badge.stopped {
background: rgba(255, 59, 48, 0.1);
color: var(--destructive-color);
border: 0.5px solid rgba(255, 59, 48, 0.3);
cursor: pointer;
}
.status-badge.stopped:hover {
opacity: 0.8;
transform: scale(0.95);
}
.status-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.server-addresses {
display: flex;
flex-direction: column;
gap: 4px;
}
.address-row {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
}
.address-icon {
width: 14px;
text-align: center;
color: var(--success-color);
}
.address-label {
color: var(--text-secondary);
}
.address-link {
font-family: 'SF Mono', Consolas, monospace;
color: var(--success-color);
text-decoration: underline;
cursor: pointer;
background: none;
border: none;
padding: 0;
}
.address-link:hover {
opacity: 0.8;
}
/* Session List */
.session-list {
flex: 1;
overflow-y: auto;
padding: 8px;
max-height: 600px;
}
.session-section {
margin-bottom: 16px;
}
.session-section-title {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 8px;
}
.session-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s ease;
margin-bottom: 4px;
position: relative;
}
.session-row:hover {
background: var(--hover-bg);
}
.session-row:focus {
outline: none;
box-shadow: 0 0 0 1px var(--focus-ring);
}
.activity-indicator {
position: relative;
margin-top: 4px;
}
.activity-glow {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.3;
filter: blur(2px);
}
.activity-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
position: relative;
left: 2px;
top: 2px;
}
.session-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.session-header {
display: flex;
align-items: center;
gap: 4px;
}
.session-command {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-name {
font-size: 12px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-details {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
}
.folder-button {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s ease;
background: none;
border: none;
color: var(--text-secondary);
}
.folder-button:hover {
background: rgba(0, 0, 0, 0.05);
}
.folder-path {
font-family: 'SF Mono', Consolas, monospace;
}
.git-info {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(52, 199, 89, 0.1);
color: var(--git-clean);
}
.git-info.modified {
background: rgba(255, 149, 0, 0.1);
color: var(--git-modified);
}
.duration {
color: var(--text-secondary);
opacity: 0.6;
margin-left: auto;
}
.close-button {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 59, 48, 0.1);
border: 0.5px solid rgba(255, 59, 48, 0.3);
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
}
.session-row:hover .close-button {
display: flex;
}
.session-row:hover .duration {
display: none;
}
.close-button:hover {
transform: translateY(-50%) scale(1.1);
}
.activity-status {
font-size: 10px;
color: var(--activity-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Empty State */
.empty-state {
padding: 40px 20px;
text-align: center;
}
.empty-icon {
font-size: 48px;
opacity: 0.3;
margin-bottom: 16px;
}
.empty-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.empty-subtitle {
font-size: 12px;
color: var(--text-secondary);
}
/* Action Bar */
.action-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary, #f8f9fa);
}
.action-button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: var(--text-primary);
transition: all 0.15s ease;
}
.action-button:hover {
background: var(--hover-bg);
}
.action-button.primary {
background: var(--accent-color);
color: white;
}
.action-button.primary:hover {
background: var(--accent-hover);
}
.divider {
flex: 1;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.menu-container {
animation: fadeIn 0.2s ease-out;
}
/* Scrollbar */
.session-list::-webkit-scrollbar {
width: 6px;
}
.session-list::-webkit-scrollbar-track {
background: transparent;
}
.session-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.session-list::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Loading State */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="menu-container" id="menu">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
<script>
// API communication with Tauri
const { invoke } = window.__TAURI__.core;
// State
let serverStatus = null;
let sessions = [];
let tailscaleStatus = null;
let gitRepositories = {};
// Initialize menu
async function init() {
try {
// Load initial data
await Promise.all([
loadServerStatus(),
loadSessions(),
loadTailscaleStatus()
]);
// Render menu
render();
// Set up event listeners
setupEventListeners();
// Start monitoring
startMonitoring();
} catch (error) {
console.error('Failed to initialize menu:', error);
showError();
}
}
async function loadServerStatus() {
serverStatus = await invoke('get_server_status');
}
async function loadSessions() {
sessions = await invoke('get_monitored_sessions');
// Load git info for each session
for (const session of sessions) {
if (session.cwd) {
try {
const gitInfo = await invoke('get_cached_git_repository', { path: session.cwd });
if (gitInfo) {
gitRepositories[session.cwd] = gitInfo;
}
} catch (e) {
// Ignore errors for sessions without git repos
}
}
}
}
async function loadTailscaleStatus() {
try {
tailscaleStatus = await invoke('get_tailscale_status');
} catch (e) {
tailscaleStatus = null;
}
}
function render() {
const menu = document.getElementById('menu');
const activeSessions = sessions.filter(s => s.is_active);
const idleSessions = sessions.filter(s => !s.is_active);
menu.innerHTML = `
${renderHeader()}
${renderSessionList(activeSessions, idleSessions)}
${renderActionBar()}
`;
}
function renderHeader() {
const addresses = [];
if (serverStatus?.running) {
addresses.push({
icon: '🖥',
label: 'Local:',
address: `127.0.0.1:${serverStatus.port}`
});
if (tailscaleStatus?.is_running && tailscaleStatus?.hostname) {
addresses.push({
icon: '🛡',
label: 'Tailscale:',
address: tailscaleStatus.hostname
});
}
}
return `
<div class="header">
<div class="header-content">
<div class="header-top">
<div class="app-info">
<img src="icon.png" class="app-icon" alt="VibeTunnel">
<div class="app-title">VibeTunnel</div>
</div>
<div class="status-badge ${serverStatus?.running ? 'running' : 'stopped'}"
onclick="${serverStatus?.running ? '' : 'restartServer()'}">
<div class="status-indicator"></div>
<span>${serverStatus?.running ? 'Running' : 'Stopped'}</span>
</div>
</div>
${addresses.length > 0 ? `
<div class="server-addresses">
${addresses.map(addr => `
<div class="address-row">
<span class="address-icon">${addr.icon}</span>
<span class="address-label">${addr.label}</span>
<button class="address-link" onclick="openAddress('${addr.address}')">
${addr.address}
</button>
</div>
`).join('')}
</div>
` : ''}
</div>
</div>
`;
}
function renderSessionList(activeSessions, idleSessions) {
if (sessions.length === 0) {
return `
<div class="session-list">
<div class="empty-state">
<div class="empty-icon">📂</div>
<div class="empty-title">No Active Sessions</div>
<div class="empty-subtitle">Create a new session to get started</div>
</div>
</div>
`;
}
return `
<div class="session-list">
${activeSessions.length > 0 ? `
<div class="session-section">
<div class="session-section-title">Active Sessions</div>
${activeSessions.map(session => renderSessionRow(session, true)).join('')}
</div>
` : ''}
${idleSessions.length > 0 ? `
<div class="session-section">
<div class="session-section-title">Idle Sessions</div>
${idleSessions.map(session => renderSessionRow(session, false)).join('')}
</div>
` : ''}
</div>
`;
}
function renderSessionRow(session, isActive) {
const gitInfo = gitRepositories[session.cwd];
const hasChanges = gitInfo && gitInfo.has_changes;
return `
<div class="session-row" tabindex="0" onclick="openSession('${session.id}')"
onkeydown="handleSessionKeyDown(event, '${session.id}')">
<div class="activity-indicator" style="color: ${isActive ? 'var(--activity-color)' : 'var(--git-clean)'}">
<div class="activity-glow"></div>
<div class="activity-dot"></div>
</div>
<div class="session-info">
<div class="session-header">
<span class="session-command">${getCommandName(session)}</span>
${session.name ? `
<span class="session-name"> ${session.name}</span>
` : ''}
</div>
<div class="session-details">
<button class="folder-button" onclick="openFolder('${session.cwd}'); event.stopPropagation();">
<span>📁</span>
<span class="folder-path">${compactPath(session.cwd)}</span>
</button>
${gitInfo ? `
<div class="git-info ${hasChanges ? 'modified' : ''}">
<span>${gitInfo.current_branch || 'main'}</span>
${hasChanges ? `<span>(${getGitStatusText(gitInfo)})</span>` : ''}
</div>
` : ''}
<span class="duration">${formatDuration(session.created_at)}</span>
</div>
${session.last_activity ? `
<div class="activity-status">${session.last_activity}</div>
` : ''}
</div>
<button class="close-button" onclick="terminateSession('${session.id}'); event.stopPropagation();">
<span style="font-size: 8px;">✕</span>
</button>
</div>
`;
}
function renderActionBar() {
return `
<div class="action-bar">
<button class="action-button primary" onclick="createNewSession()">
<span>+</span>
<span>New Session</span>
</button>
<div class="divider"></div>
<button class="action-button" onclick="openSettings()">
<span>⚙️</span>
<span>Settings</span>
</button>
<button class="action-button" onclick="quitApp()">
<span>Quit</span>
</button>
</div>
`;
}
// Helper functions
function getCommandName(session) {
// Extract command name from the full command
if (!session.shell || session.shell.length === 0) {
return 'Terminal';
}
const parts = session.shell.split('/');
return parts[parts.length - 1];
}
function compactPath(path) {
// Get the appropriate home directory based on the platform
const platform = navigator.platform.toLowerCase();
let homePatterns = [];
if (platform.includes('win')) {
// Windows: C:\Users\username or similar
homePatterns = [
/^[A-Za-z]:\\Users\\[^\\]+/,
/^[A-Za-z]:\\Documents and Settings\\[^\\]+/
];
} else if (platform.includes('mac')) {
// macOS: /Users/username
homePatterns = [/^\/Users\/[^\/]+/];
} else {
// Linux/Unix: /home/username
homePatterns = [/^\/home\/[^\/]+/];
}
// Try to match and replace home directory with ~
for (const pattern of homePatterns) {
const match = path.match(pattern);
if (match) {
return '~' + path.substring(match[0].length);
}
}
// Fallback: shorten long paths
const separator = path.includes('\\') ? '\\' : '/';
const parts = path.split(separator);
if (parts.length > 3) {
return '...' + separator + parts.slice(-2).join(separator);
}
return path;
}
function formatDuration(createdAt) {
const start = new Date(createdAt);
const now = new Date();
const elapsed = (now - start) / 1000; // seconds
if (elapsed < 60) return 'now';
if (elapsed < 3600) return `${Math.floor(elapsed / 60)}m`;
if (elapsed < 86400) return `${Math.floor(elapsed / 3600)}h`;
return `${Math.floor(elapsed / 86400)}d`;
}
function getGitStatusText(gitInfo) {
const parts = [];
if (gitInfo.modified_count > 0) parts.push(`${gitInfo.modified_count}M`);
if (gitInfo.added_count > 0) parts.push(`${gitInfo.added_count}+`);
if (gitInfo.deleted_count > 0) parts.push(`${gitInfo.deleted_count}-`);
if (gitInfo.untracked_count > 0) parts.push(`${gitInfo.untracked_count}?`);
return parts.join(' ');
}
// Event handlers
async function openSession(sessionId) {
try {
// First try to focus terminal window
await invoke('focus_terminal_window', { sessionId });
} catch (e) {
// If no window, open in browser
await invoke('open_dashboard');
}
}
async function terminateSession(sessionId) {
try {
// TODO: Implement session termination
console.log('Terminate session:', sessionId);
} catch (error) {
console.error('Failed to terminate session:', error);
}
}
async function openFolder(path) {
try {
await invoke('open_folder', { path });
} catch (error) {
console.error('Failed to open folder:', error);
}
}
async function openAddress(address) {
if (address.includes('127.0.0.1')) {
await invoke('open_dashboard');
} else {
// Open other addresses directly
window.open(`http://${address}`, '_blank');
}
}
async function restartServer() {
try {
await invoke('restart_server');
await loadServerStatus();
render();
} catch (error) {
console.error('Failed to restart server:', error);
}
}
async function createNewSession() {
// This would open a new session form
// For now, just open the dashboard
await invoke('open_dashboard');
}
async function openSettings() {
await invoke('open_settings_window');
}
async function quitApp() {
await invoke('quit_app');
}
function handleSessionKeyDown(event, sessionId) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openSession(sessionId);
}
}
function setupEventListeners() {
// Listen for Tauri events
window.__TAURI__.event.listen('server-status-changed', async () => {
await loadServerStatus();
render();
});
window.__TAURI__.event.listen('sessions-changed', async () => {
await loadSessions();
render();
});
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
window.close();
}
});
}
function startMonitoring() {
// Refresh data periodically
setInterval(async () => {
await loadSessions();
render();
}, 5000);
}
function showError() {
const menu = document.getElementById('menu');
menu.innerHTML = `
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<div class="empty-title">Connection Error</div>
<div class="empty-subtitle">Failed to connect to VibeTunnel service</div>
</div>
`;
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>