diff --git a/main.js b/main.js index fd414c6..ebfa277 100644 --- a/main.js +++ b/main.js @@ -11,6 +11,7 @@ const { assertPathAllowed, addAllowedRoot } = require('./path-guard'); const { buildClaudeCmd } = require('./claude-cmd'); const { isSafeExternalUrl } = require('./url-guard'); const branding = require('./branding'); +const profilesModule = require('./profiles'); // Sync IPC for the preload to fetch the brand-strings snapshot at load // time. ipcMain.on handles sendSync via event.returnValue. @@ -1120,6 +1121,21 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se ptyEnv.CLAUDE_CODE_SSE_PORT = String(mcpServer.port); } + // Apply Claude profile env (per-session profileId → fall back to global default). + // Profile values override base env. Refs like "$DEEPSEEK_API_KEY" resolve against + // the host process env; unresolved refs are dropped (not passed through literally). + try { + const profile = profilesModule.pickProfileForSession(sessionOptions?.profileId); + if (profile) { + const resolved = profilesModule.resolveEnv(profile.env); + Object.assign(ptyEnv, resolved); + const dropped = Object.keys(profile.env).filter(k => !(k in resolved)); + log.info(`[profiles] Applied "${profile.name}" (${profile.id}): ${Object.keys(resolved).length} var(s)${dropped.length ? `, ${dropped.length} unresolved ref(s) dropped: ${dropped.join(',')}` : ''}`); + } + } catch (err) { + log.error(`[profiles] Failed to apply profile: ${err.message}`); + } + // Schedule cleanup of the system-prompt temp file once the shell has // had time to $(cat ...) it into the command line. Also unlink on // session exit and app quit as belt-and-braces. @@ -1451,6 +1467,7 @@ app.whenReady().then(() => { } scheduleIpc.init(log, runScheduleCommand); + profilesModule.init(log); startScheduler(log, runScheduleCommand); // Re-index search if FTS table was recreated (e.g. tokenizer config change) diff --git a/preload.js b/preload.js index 25fd4db..d1dff1e 100644 --- a/preload.js +++ b/preload.js @@ -42,6 +42,14 @@ contextBridge.exposeInMainWorld('api', { runScheduleNow: (filePath) => ipcRenderer.invoke('run-schedule-now', filePath), getShellProfiles: () => ipcRenderer.invoke('get-shell-profiles'), + // Claude profiles (env-var bundles applied at session spawn) + profiles: { + list: () => ipcRenderer.invoke('profiles:list'), + save: (profile) => ipcRenderer.invoke('profiles:save', profile), + delete: (id) => ipcRenderer.invoke('profiles:delete', id), + setDefault: (id) => ipcRenderer.invoke('profiles:set-default', id), + }, + browseFolder: () => ipcRenderer.invoke('browse-folder'), addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), diff --git a/profiles.js b/profiles.js new file mode 100644 index 0000000..49f0797 --- /dev/null +++ b/profiles.js @@ -0,0 +1,183 @@ +// profiles.js — Claude session profiles: per-profile env var overrides +// +// A profile is a named bundle of environment variables that get merged into +// a session's pty env at spawn time. Values can be either: +// - literal strings (e.g. "https://api.deepseek.com/anthropic") +// - references to system environment variables: "$DEEPSEEK_API_KEY" +// or "${DEEPSEEK_API_KEY}". Unresolved refs are dropped (not passed +// through as the literal string), so secrets never leak into the +// command line if the host env var is missing. +// +// Persistence: /profiles.json. Plain JSON (no encryption) since +// values are either literals or *references*; actual secrets stay in the +// host process env. Atomic write via tmp+rename. + +const fs = require('fs'); +const path = require('path'); + +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const ENV_REF_RE = /^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/; +const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; +const MAX_PROFILES = 32; +const MAX_ENV_VARS = 64; +const MAX_VALUE_LEN = 4096; + +let _profilesPathOverride = null; + +function setProfilesPathForTesting(p) { _profilesPathOverride = p; } + +function profilesPath() { + if (_profilesPathOverride) return _profilesPathOverride; + const { app } = require('electron'); + return path.join(app.getPath('userData'), 'profiles.json'); +} + +function isPlainObject(o) { + return o !== null && typeof o === 'object' && !Array.isArray(o); +} + +function isValidProfile(p) { + if (!isPlainObject(p)) return false; + if (typeof p.id !== 'string' || !ID_RE.test(p.id)) return false; + if (typeof p.name !== 'string' || !p.name.trim() || p.name.length > 100) return false; + if (!isPlainObject(p.env)) return false; + const keys = Object.keys(p.env); + if (keys.length > MAX_ENV_VARS) return false; + for (const k of keys) { + if (!ENV_NAME_RE.test(k)) return false; + const v = p.env[k]; + if (typeof v !== 'string' || v.length > MAX_VALUE_LEN) return false; + } + return true; +} + +function emptyState() { return { profiles: [], defaultProfileId: null }; } + +function loadProfiles() { + try { + const raw = fs.readFileSync(profilesPath(), 'utf8'); + const data = JSON.parse(raw); + if (!isPlainObject(data)) return emptyState(); + const profiles = Array.isArray(data.profiles) + ? data.profiles.filter(isValidProfile).slice(0, MAX_PROFILES) + : []; + const defaultProfileId = (typeof data.defaultProfileId === 'string' + && profiles.find(p => p.id === data.defaultProfileId)) + ? data.defaultProfileId + : null; + return { profiles, defaultProfileId }; + } catch { + return emptyState(); + } +} + +function saveProfiles(state) { + const target = profilesPath(); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const tmp = target + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2)); + fs.renameSync(tmp, target); +} + +// Resolve a profile's env map: substitute $VAR / ${VAR} references against +// processEnv (defaults to process.env). Unresolved refs are DROPPED, not +// passed through as the literal "$VAR" string. +function resolveEnv(envMap, processEnv) { + const env = processEnv || process.env; + const out = {}; + if (!isPlainObject(envMap)) return out; + for (const [k, v] of Object.entries(envMap)) { + if (typeof v !== 'string') continue; + const m = ENV_REF_RE.exec(v); + if (m) { + const refName = m[1] || m[2]; + const resolved = env[refName]; + if (typeof resolved === 'string' && resolved.length > 0) { + out[k] = resolved; + } + // unresolved → skip + } else { + out[k] = v; + } + } + return out; +} + +function getProfileById(id) { + if (typeof id !== 'string' || !id) return null; + const { profiles } = loadProfiles(); + return profiles.find(p => p.id === id) || null; +} + +function getDefaultProfile() { + const { profiles, defaultProfileId } = loadProfiles(); + if (!defaultProfileId) return null; + return profiles.find(p => p.id === defaultProfileId) || null; +} + +// Pick the profile to apply for a session given a per-session profileId +// (which may be undefined, meaning "use default", or the literal string +// "none", meaning "no profile — pass-through"). +function pickProfileForSession(profileId) { + if (profileId === 'none') return null; + if (profileId) return getProfileById(profileId); + return getDefaultProfile(); +} + +function init(log) { + const { ipcMain } = require('electron'); + + ipcMain.handle('profiles:list', () => loadProfiles()); + + ipcMain.handle('profiles:save', (_e, profile) => { + if (!isValidProfile(profile)) return { ok: false, error: 'invalid profile' }; + const state = loadProfiles(); + const idx = state.profiles.findIndex(p => p.id === profile.id); + if (idx >= 0) { + state.profiles[idx] = profile; + } else { + if (state.profiles.length >= MAX_PROFILES) { + return { ok: false, error: `max ${MAX_PROFILES} profiles` }; + } + state.profiles.push(profile); + } + saveProfiles(state); + if (log) log.info(`[profiles] Saved profile "${profile.name}" (${profile.id})`); + return { ok: true }; + }); + + ipcMain.handle('profiles:delete', (_e, id) => { + if (typeof id !== 'string' || !ID_RE.test(id)) return { ok: false, error: 'invalid id' }; + const state = loadProfiles(); + const before = state.profiles.length; + state.profiles = state.profiles.filter(p => p.id !== id); + if (state.defaultProfileId === id) state.defaultProfileId = null; + saveProfiles(state); + if (log) log.info(`[profiles] Deleted profile ${id} (${before - state.profiles.length} removed)`); + return { ok: true }; + }); + + ipcMain.handle('profiles:set-default', (_e, id) => { + const state = loadProfiles(); + if (id && (typeof id !== 'string' || !state.profiles.find(p => p.id === id))) { + return { ok: false, error: 'unknown profile' }; + } + state.defaultProfileId = id || null; + saveProfiles(state); + return { ok: true }; + }); +} + +module.exports = { + init, + loadProfiles, + saveProfiles, + resolveEnv, + getProfileById, + getDefaultProfile, + pickProfileForSession, + isValidProfile, + setProfilesPathForTesting, + ENV_REF_RE, + ENV_NAME_RE, +}; diff --git a/public/app.js b/public/app.js index 6a8f684..4a9341c 100644 --- a/public/app.js +++ b/public/app.js @@ -1,3 +1,10 @@ +// Apply persisted light UI mode (toggled in Global Settings → "Light UI mode") +try { + if (localStorage.getItem('lightUiMode') === '1') { + document.body.classList.add('theme-light'); + } +} catch {} + const statusBarInfo = document.getElementById('status-bar-info'); const statusBarActivity = document.getElementById('status-bar-activity'); const terminalsEl = document.getElementById('terminals'); diff --git a/public/dialogs.js b/public/dialogs.js index 836ce2d..c7dc4dd 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -76,10 +76,14 @@ async function launchScheduleCreator(project) { pollActiveSessions(); } -function showNewSessionPopover(project, anchorEl) { +async function showNewSessionPopover(project, anchorEl) { // Remove any existing popover document.querySelectorAll('.new-session-popover').forEach(el => el.remove()); + // Load profiles so we can offer per-profile launch entries. + let profilesData = { profiles: [], defaultProfileId: null }; + try { profilesData = await window.api.profiles.list(); } catch {} + const popover = document.createElement('div'); popover.className = 'new-session-popover'; @@ -99,6 +103,51 @@ function showNewSessionPopover(project, anchorEl) { termBtn.onclick = () => { popover.remove(); launchTerminalSession(project); }; popover.appendChild(claudeBtn); + + // Per-profile launch entries — one button per saved profile. + // Default profile shows "(default)" suffix; clicking launches with that profile. + // If no profiles exist, this section is empty. + if (profilesData.profiles.length > 0) { + const sep = document.createElement('div'); + sep.className = 'popover-separator'; + sep.textContent = 'Profiles'; + popover.appendChild(sep); + for (const profile of profilesData.profiles) { + const isDefault = profile.id === profilesData.defaultProfileId; + const profBtn = document.createElement('button'); + profBtn.className = 'popover-option popover-option-profile'; + // Static SVG icon assigned via innerHTML; profile.name is appended as text + // (createTextNode), which is XSS-safe regardless of name content. + profBtn.innerHTML = ' '; + profBtn.appendChild(document.createTextNode(profile.name)); + if (isDefault) { + const tag = document.createElement('span'); + tag.className = 'popover-default-tag'; + tag.textContent = 'default'; + profBtn.appendChild(document.createTextNode(' ')); + profBtn.appendChild(tag); + } + profBtn.onclick = async () => { + popover.remove(); + const opts = await resolveDefaultSessionOptions(project); + opts.profileId = profile.id; + launchNewSession(project, opts); + }; + popover.appendChild(profBtn); + } + } + + // Manage profiles entry + const manageBtn = document.createElement('button'); + manageBtn.className = 'popover-option popover-option-manage'; + manageBtn.innerHTML = ` Manage profiles…`; + manageBtn.onclick = () => { popover.remove(); window.showProfilesManager && window.showProfilesManager(); }; + popover.appendChild(manageBtn); + + const sep2 = document.createElement('div'); + sep2.className = 'popover-separator'; + popover.appendChild(sep2); + popover.appendChild(claudeOptsBtn); popover.appendChild(termBtn); @@ -171,6 +220,8 @@ async function launchTerminalSession(project) { async function showNewSessionDialog(project) { const effective = await window.api.getEffectiveSettings(project.projectPath); + let profilesData = { profiles: [], defaultProfileId: null }; + try { profilesData = await window.api.profiles.list(); } catch {} const overlay = document.createElement('div'); overlay.className = 'new-session-overlay'; @@ -180,6 +231,8 @@ async function showNewSessionDialog(project) { let selectedMode = effective.permissionMode || null; let dangerousSkip = effective.dangerouslySkipPermissions || false; + // Default profile selection: '' = "use global default" (whatever is set), 'none' = no profile. + let selectedProfileId = ''; const modes = [ { value: null, label: 'Default', desc: 'Prompt for all actions' }, @@ -203,6 +256,21 @@ async function showNewSessionDialog(project) {
Permission Mode
${renderModeGrid()}
+
+
+ Profile +
Override env vars (e.g. ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN) at spawn
+
+
+ +
+
Worktree @@ -287,6 +355,8 @@ async function showNewSessionDialog(project) { if (preLaunch) options.preLaunchCmd = preLaunch; options.addDirs = dialog.querySelector('#nsd-add-dirs').value.trim(); if (effective.mcpEmulation === false) options.mcpEmulation = false; + const profileSel = dialog.querySelector('#nsd-profile').value; + if (profileSel) options.profileId = profileSel; // '' means use default; otherwise either 'none' or a profile id close(); launchNewSession(project, options); } diff --git a/public/index.html b/public/index.html index 642eb35..b41acbe 100644 --- a/public/index.html +++ b/public/index.html @@ -111,6 +111,7 @@ + diff --git a/public/profiles-panel.js b/public/profiles-panel.js new file mode 100644 index 0000000..3cce47f --- /dev/null +++ b/public/profiles-panel.js @@ -0,0 +1,245 @@ +// profiles-panel.js — modal UI to manage Claude session profiles. +// Each profile is a named bundle of env vars (literal values or "$VAR" / +// "${VAR}" references resolved against the host's process env at spawn). + +(function () { + function uid() { + // base36, 10 chars, fits ID_RE in profiles.js (alnum + - _, len ≤ 64) + return Math.random().toString(36).slice(2, 12); + } + + function el(tag, cls, text) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + + function isEnvRef(v) { + return /^\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)$/.test(v || ''); + } + + // --- Editor for a single profile --------------------------------------- + function renderEditor(profile, onSave, onCancel, onDelete) { + const overlay = el('div', 'new-session-overlay'); + const dialog = el('div', 'new-session-dialog profiles-dialog'); + + const isNew = !profile; + const state = profile + ? { id: profile.id, name: profile.name, env: { ...profile.env } } + : { id: uid(), name: '', env: {} }; + + const title = el('h3', null, isNew ? 'New Profile' : `Edit Profile — ${state.name || state.id}`); + dialog.appendChild(title); + + // Name field + const nameField = el('div', 'settings-field settings-field-wide'); + nameField.innerHTML = ` +
+ Name +
Display name (e.g. "DeepSeek", "GLM", "Anthropic Default")
+
+
+ +
`; + const nameInput = nameField.querySelector('.profile-name-input'); + nameInput.value = state.name; + dialog.appendChild(nameField); + + // Env vars section + const envSection = el('div', 'settings-field settings-field-wide profile-env-section'); + envSection.innerHTML = ` +
+ Environment Variables +
+ Literal value, or $VAR / \${VAR} to reference a system env var + (recommended for secrets like API keys). Unresolved refs are dropped at spawn time. +
+
+
+ `; + const rowsEl = envSection.querySelector('.profile-env-rows'); + dialog.appendChild(envSection); + + function addRow(key, value) { + const row = el('div', 'profile-env-row'); + row.innerHTML = ` + + + + `; + const keyInput = row.querySelector('.profile-env-key'); + const valInput = row.querySelector('.profile-env-val'); + const status = row.querySelector('.profile-env-status'); + keyInput.value = key || ''; + valInput.value = value || ''; + function refreshStatus() { + status.className = 'profile-env-status'; + status.textContent = ''; + if (isEnvRef(valInput.value)) { + status.textContent = 'env ref'; + status.classList.add('is-ref'); + } else if (valInput.value) { + status.textContent = 'literal'; + status.classList.add('is-literal'); + } + } + valInput.addEventListener('input', refreshStatus); + refreshStatus(); + row.querySelector('.profile-env-remove').onclick = () => row.remove(); + rowsEl.appendChild(row); + } + + Object.entries(state.env).forEach(([k, v]) => addRow(k, v)); + if (Object.keys(state.env).length === 0) { + addRow('ANTHROPIC_BASE_URL', ''); + addRow('ANTHROPIC_AUTH_TOKEN', ''); + } + envSection.querySelector('.profile-env-add').onclick = () => addRow('', ''); + + // Hint suggesting the common pattern + const hint = el('div', 'profile-hint'); + hint.innerHTML = ` + Tip: store secrets as system env vars (e.g. + setx DEEPSEEK_API_KEY ...) and reference them here as + $DEEPSEEK_API_KEY. Profile values are saved in plain text; + references keep secrets out of this file.`; + dialog.appendChild(hint); + + // Actions + const actions = el('div', 'new-session-actions'); + const cancelBtn = el('button', 'new-session-cancel-btn', 'Cancel'); + const saveBtn = el('button', 'new-session-start-btn', isNew ? 'Create' : 'Save'); + let deleteBtn = null; + if (!isNew) { + deleteBtn = el('button', 'new-session-cancel-btn profile-delete-btn', 'Delete'); + deleteBtn.style.marginRight = 'auto'; + actions.appendChild(deleteBtn); + } + actions.appendChild(cancelBtn); + actions.appendChild(saveBtn); + dialog.appendChild(actions); + + overlay.appendChild(dialog); + document.body.appendChild(overlay); + nameInput.focus(); + + function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } + function onKey(e) { if (e.key === 'Escape') { close(); onCancel && onCancel(); } } + document.addEventListener('keydown', onKey); + + cancelBtn.onclick = () => { close(); onCancel && onCancel(); }; + overlay.addEventListener('click', (e) => { if (e.target === overlay) { close(); onCancel && onCancel(); } }); + + if (deleteBtn) { + deleteBtn.onclick = async () => { + if (!confirm(`Delete profile "${state.name || state.id}"?`)) return; + await window.api.profiles.delete(state.id); + close(); + onDelete && onDelete(); + }; + } + + saveBtn.onclick = async () => { + const name = nameInput.value.trim(); + if (!name) { nameInput.focus(); return; } + const env = {}; + let invalid = null; + for (const row of rowsEl.querySelectorAll('.profile-env-row')) { + const k = row.querySelector('.profile-env-key').value.trim(); + const v = row.querySelector('.profile-env-val').value; + if (!k && !v) continue; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) { invalid = `Invalid env name: "${k}"`; break; } + if (k in env) { invalid = `Duplicate env name: "${k}"`; break; } + env[k] = v; + } + if (invalid) { alert(invalid); return; } + + const result = await window.api.profiles.save({ id: state.id, name, env }); + if (!result.ok) { alert(`Save failed: ${result.error}`); return; } + close(); + onSave && onSave(); + }; + } + + // --- List view: pick / edit / create profiles -------------------------- + async function showProfilesManager() { + const data = await window.api.profiles.list(); + + const overlay = el('div', 'new-session-overlay'); + const dialog = el('div', 'new-session-dialog profiles-dialog'); + dialog.appendChild(el('h3', null, 'Claude Profiles')); + + const desc = el('div', 'settings-description profiles-help'); + desc.innerHTML = ` + Profiles are named bundles of environment variables applied when launching a Claude session. + Use them to switch backends — e.g. point ANTHROPIC_BASE_URL at DeepSeek or GLM + and reference your API keys as $DEEPSEEK_API_KEY / $GLM_API_KEY. + One profile can be marked as the global default.`; + dialog.appendChild(desc); + + const list = el('div', 'profiles-list'); + if (data.profiles.length === 0) { + const empty = el('div', 'profiles-empty', 'No profiles yet. Click + New to create one.'); + list.appendChild(empty); + } + for (const p of data.profiles) { + const isDefault = p.id === data.defaultProfileId; + const row = el('div', 'profile-row'); + const meta = el('div', 'profile-row-meta'); + const nm = el('div', 'profile-row-name', p.name); + if (isDefault) { + const badge = el('span', 'profile-default-badge', 'default'); + nm.appendChild(badge); + } + const sub = el('div', 'profile-row-sub', `${Object.keys(p.env).length} env var(s)`); + meta.appendChild(nm); + meta.appendChild(sub); + row.appendChild(meta); + + const actions = el('div', 'profile-row-actions'); + const setDefaultBtn = el('button', 'profile-row-btn'); + setDefaultBtn.textContent = isDefault ? 'Default ✓' : 'Set default'; + setDefaultBtn.onclick = async () => { + await window.api.profiles.setDefault(isDefault ? null : p.id); + overlay.remove(); + showProfilesManager(); + }; + const editBtn = el('button', 'profile-row-btn', 'Edit'); + editBtn.onclick = () => { + overlay.remove(); + renderEditor(p, () => showProfilesManager(), () => showProfilesManager(), () => showProfilesManager()); + }; + actions.appendChild(setDefaultBtn); + actions.appendChild(editBtn); + row.appendChild(actions); + list.appendChild(row); + } + dialog.appendChild(list); + + const newBtn = el('button', 'new-session-start-btn profile-new-btn', '+ New profile'); + newBtn.onclick = () => { + overlay.remove(); + renderEditor(null, () => showProfilesManager(), () => showProfilesManager()); + }; + + const closeBtn = el('button', 'new-session-cancel-btn', 'Close'); + closeBtn.onclick = () => overlay.remove(); + + const actions = el('div', 'new-session-actions'); + actions.appendChild(newBtn); + actions.appendChild(closeBtn); + dialog.appendChild(actions); + + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + function onKey(e) { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', onKey); } } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); + } + + // Export + window.showProfilesManager = showProfilesManager; + window.profilesPanel = { showProfilesManager }; +})(); diff --git a/public/settings-panel.js b/public/settings-panel.js index 2065bcc..5f20c86 100644 --- a/public/settings-panel.js +++ b/public/settings-panel.js @@ -190,6 +190,16 @@
+
+
+ Light UI Mode +
Brighter sidebar / chrome / dialogs (terminal theme is set above)
+
+
+ +
+
+
Shell Profile @@ -302,6 +312,18 @@ settings.terminalTheme = settingsViewerBody.querySelector('#sv-terminal-theme').value || 'switchboard'; settings.mcpEmulation = settingsViewerBody.querySelector('#sv-mcp-emulation').checked; settings.shellProfile = settingsViewerBody.querySelector('#sv-shell-profile').value || 'auto'; + + // Light UI mode is a local preference — apply immediately and persist in localStorage. + const lightUi = settingsViewerBody.querySelector('#sv-light-ui'); + if (lightUi) { + if (lightUi.checked) { + try { localStorage.setItem('lightUiMode', '1'); } catch {} + document.body.classList.add('theme-light'); + } else { + try { localStorage.removeItem('lightUiMode'); } catch {} + document.body.classList.remove('theme-light'); + } + } } // Merge form values into existing settings to preserve keys not managed by the form diff --git a/public/style.css b/public/style.css index f6070c0..177071c 100644 --- a/public/style.css +++ b/public/style.css @@ -3690,3 +3690,238 @@ body { display: flex; flex-direction: column; } margin-right: 5px; vertical-align: middle; } + +/* --- Profiles UI --------------------------------------------------------- */ +.popover-separator { + height: 1px; + background: #2d2d44; + margin: 4px 8px; + font-size: 10px; + color: #888; + text-transform: uppercase; + letter-spacing: 0.06em; + position: relative; +} +.popover-separator:not(:empty) { + background: transparent; + padding: 6px 12px 2px; + height: auto; + margin: 0; +} +.popover-default-tag { + font-size: 10px; + color: #8088ff; + margin-left: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.popover-option-profile .popover-option-icon, +.popover-option-manage .popover-option-icon { + color: #c0c0d0; +} + +.profiles-dialog .profiles-help { + margin: -4px 0 16px; + line-height: 1.5; +} +.profiles-dialog .profiles-list { + margin: 8px 0 16px; + display: flex; + flex-direction: column; + gap: 6px; +} +.profiles-dialog .profile-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: #1f1f30; + border: 1px solid #2d2d44; + border-radius: 6px; +} +.profiles-dialog .profile-row-name { + font-weight: 600; + font-size: 14px; +} +.profile-default-badge { + font-size: 10px; + color: #8088ff; + background: rgba(128, 136, 255, 0.12); + padding: 1px 6px; + border-radius: 4px; + margin-left: 8px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.profiles-dialog .profile-row-sub { + font-size: 12px; + color: #888; + margin-top: 2px; +} +.profiles-dialog .profile-row-actions { + display: flex; + gap: 6px; +} +.profile-row-btn { + background: #2a2a3e; + color: #d0d0e0; + border: 1px solid #3a3a55; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; +} +.profile-row-btn:hover { background: #34344a; } +.profiles-dialog .profiles-empty { + padding: 16px; + text-align: center; + color: #888; + font-style: italic; +} + +.profile-env-section .profile-env-rows { + display: flex; + flex-direction: column; + gap: 6px; + margin: 6px 0; +} +.profile-env-row { + display: grid; + grid-template-columns: 1fr 1.5fr auto auto; + gap: 8px; + align-items: center; +} +.profile-env-row .profile-env-key, +.profile-env-row .profile-env-val { + width: 100%; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} +.profile-env-status { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + width: 56px; + text-align: center; +} +.profile-env-status.is-ref { color: #8088ff; } +.profile-env-status.is-literal { color: #aaa; } +.profile-env-remove { + background: transparent; + color: #aaa; + border: 1px solid #3a3a55; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + line-height: 1; +} +.profile-env-remove:hover { background: #3a2a3a; color: #ff6b81; } +.profile-env-add { + background: #2a2a3e; + color: #d0d0e0; + border: 1px dashed #3a3a55; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-top: 4px; +} +.profile-env-add:hover { background: #34344a; } +.profile-hint { + margin: 12px 0; + padding: 10px 12px; + background: rgba(128, 136, 255, 0.06); + border-left: 3px solid #8088ff; + font-size: 12px; + line-height: 1.5; + color: #b8b8c8; + border-radius: 0 4px 4px 0; +} +.profile-hint code { + background: rgba(0, 0, 0, 0.3); + padding: 1px 4px; + border-radius: 3px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; +} +.profile-delete-btn { + color: #ff6b81 !important; + border-color: #5a2a3a !important; +} +.profile-delete-btn:hover { background: #3a1a2a !important; } +.profile-new-btn { + margin-right: auto; +} + +/* --- Light theme override (toggled via body.theme-light) ----------------- */ +body.theme-light { + background: #f5f5f7; + color: #1d1d1f; +} +body.theme-light #sidebar { + background: #ebebef; + color: #1d1d1f; + border-right: 1px solid #d1d1d6; +} +body.theme-light .session-item, +body.theme-light .project-header { + color: #1d1d1f; +} +body.theme-light .session-item:hover, +body.theme-light .project-header:hover { + background: #dcdce0; +} +body.theme-light .session-item.active, +body.theme-light .session-item.selected { + background: #d0d0d6; +} +body.theme-light #terminal-header, +body.theme-light .terminal-header { + background: #ebebef; + color: #1d1d1f; + border-bottom: 1px solid #d1d1d6; +} +body.theme-light .new-session-overlay { + background: rgba(0, 0, 0, 0.35); +} +body.theme-light .new-session-dialog, +body.theme-light .new-session-popover { + background: #ffffff; + color: #1d1d1f; + border: 1px solid #d1d1d6; +} +body.theme-light .popover-option { + color: #1d1d1f; +} +body.theme-light .popover-option:hover { + background: #ececef; +} +body.theme-light .popover-separator { + background: #d1d1d6; + color: #6b6b70; +} +body.theme-light .settings-input, +body.theme-light input[type="text"], +body.theme-light select, +body.theme-light textarea { + background: #ffffff; + color: #1d1d1f; + border: 1px solid #d1d1d6; +} +body.theme-light .profile-row { + background: #f5f5f7; + border-color: #d1d1d6; +} +body.theme-light .profile-row-btn, +body.theme-light .profile-env-add, +body.theme-light .profile-env-remove { + background: #ebebef; + color: #1d1d1f; + border-color: #d1d1d6; +} +body.theme-light .profile-row-btn:hover, +body.theme-light .profile-env-add:hover { + background: #dcdce0; +} diff --git a/public/terminal-themes.js b/public/terminal-themes.js index 307fb2d..51705d3 100644 --- a/public/terminal-themes.js +++ b/public/terminal-themes.js @@ -42,6 +42,18 @@ const TERMINAL_THEMES = { black: '#073642', red: '#dc322f', green: '#859900', yellow: '#b58900', blue: '#268bd2', magenta: '#d33682', cyan: '#2aa198', white: '#eee8d5', brightBlack: '#002b36', brightRed: '#cb4b16', brightGreen: '#586e75', brightYellow: '#657b83', brightBlue: '#839496', brightMagenta: '#6c71c4', brightCyan: '#93a1a1', brightWhite: '#fdf6e3', }, + light: { + label: 'Light', + background: '#fafafa', foreground: '#1d1d1f', cursor: '#1d1d1f', selectionBackground: '#cdd6e3', + black: '#1d1d1f', red: '#c0392b', green: '#27867a', yellow: '#9c6a00', blue: '#1f6feb', magenta: '#a347b9', cyan: '#0e7c86', white: '#1d1d1f', + brightBlack: '#6b6b70', brightRed: '#d63b32', brightGreen: '#2ea88a', brightYellow: '#b87900', brightBlue: '#3a7eed', brightMagenta: '#bb56cf', brightCyan: '#1a8e96', brightWhite: '#1d1d1f', + }, + solarizedLight: { + label: 'Solarized Light', + background: '#fdf6e3', foreground: '#586e75', cursor: '#586e75', selectionBackground: '#eee8d5', + black: '#073642', red: '#dc322f', green: '#859900', yellow: '#b58900', blue: '#268bd2', magenta: '#d33682', cyan: '#2aa198', white: '#eee8d5', + brightBlack: '#002b36', brightRed: '#cb4b16', brightGreen: '#586e75', brightYellow: '#657b83', brightBlue: '#839496', brightMagenta: '#6c71c4', brightCyan: '#93a1a1', brightWhite: '#fdf6e3', + }, }; let currentThemeName = 'switchboard'; diff --git a/test/profiles.test.js b/test/profiles.test.js new file mode 100644 index 0000000..f0fa2ec --- /dev/null +++ b/test/profiles.test.js @@ -0,0 +1,184 @@ +// test/profiles.test.js — unit tests for profiles module +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const profiles = require('../profiles.js'); + +function tmpFile() { + return path.join(os.tmpdir(), `switchboard-profiles-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); +} + +test('isValidProfile accepts a well-formed profile', () => { + assert.strictEqual(profiles.isValidProfile({ + id: 'deepseek', + name: 'DeepSeek', + env: { ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', ANTHROPIC_AUTH_TOKEN: '$DEEPSEEK_API_KEY' }, + }), true); +}); + +test('isValidProfile rejects bad ids, names, env', () => { + const base = { id: 'a', name: 'A', env: {} }; + assert.strictEqual(profiles.isValidProfile({ ...base, id: 'has spaces' }), false); + assert.strictEqual(profiles.isValidProfile({ ...base, id: '' }), false); + assert.strictEqual(profiles.isValidProfile({ ...base, name: '' }), false); + assert.strictEqual(profiles.isValidProfile({ ...base, name: ' ' }), false); + assert.strictEqual(profiles.isValidProfile({ ...base, env: null }), false); + assert.strictEqual(profiles.isValidProfile({ ...base, env: { '1BAD': 'x' } }), false); + assert.strictEqual(profiles.isValidProfile({ ...base, env: { 'KEY': 123 } }), false); + assert.strictEqual(profiles.isValidProfile({ ...base, env: { 'KEY-WITH-DASH': 'x' } }), false); +}); + +test('isValidProfile rejects oversized env', () => { + const env = {}; + for (let i = 0; i < 100; i++) env[`K${i}`] = 'v'; + assert.strictEqual(profiles.isValidProfile({ id: 'a', name: 'A', env }), false); +}); + +test('resolveEnv substitutes $VAR and ${VAR} from process env', () => { + const out = profiles.resolveEnv({ + LITERAL: 'hello', + DOLLAR_REF: '$MY_TEST_KEY', + BRACE_REF: '${MY_TEST_KEY}', + }, { MY_TEST_KEY: 'secret-value' }); + assert.deepStrictEqual(out, { LITERAL: 'hello', DOLLAR_REF: 'secret-value', BRACE_REF: 'secret-value' }); +}); + +test('resolveEnv DROPS unresolved references (does not pass through literal $VAR)', () => { + const out = profiles.resolveEnv({ + LITERAL: 'hello', + MISSING_REF: '$NOT_SET_ANYWHERE_XYZ', + }, {}); + assert.deepStrictEqual(out, { LITERAL: 'hello' }); +}); + +test('resolveEnv treats values with $ in middle as literals (not refs)', () => { + const out = profiles.resolveEnv({ + URL: 'https://example.com/$path', + PRICE: '$5.00', + }, { path: 'should-not-substitute' }); + assert.deepStrictEqual(out, { + URL: 'https://example.com/$path', + PRICE: '$5.00', + }); +}); + +test('resolveEnv drops empty resolved values', () => { + const out = profiles.resolveEnv({ KEY: '$EMPTY' }, { EMPTY: '' }); + assert.deepStrictEqual(out, {}); +}); + +test('save/load round-trip', () => { + const p = tmpFile(); + profiles.setProfilesPathForTesting(p); + try { + const state = { + profiles: [ + { id: 'a', name: 'A', env: { K: 'v' } }, + { id: 'b', name: 'B', env: { K2: '$REF' } }, + ], + defaultProfileId: 'a', + }; + profiles.saveProfiles(state); + const loaded = profiles.loadProfiles(); + assert.deepStrictEqual(loaded, state); + } finally { + profiles.setProfilesPathForTesting(null); + try { fs.unlinkSync(p); } catch {} + } +}); + +test('loadProfiles returns empty state for missing file', () => { + const p = tmpFile(); + profiles.setProfilesPathForTesting(p); + try { + const loaded = profiles.loadProfiles(); + assert.deepStrictEqual(loaded, { profiles: [], defaultProfileId: null }); + } finally { + profiles.setProfilesPathForTesting(null); + } +}); + +test('loadProfiles drops invalid entries silently', () => { + const p = tmpFile(); + profiles.setProfilesPathForTesting(p); + try { + fs.writeFileSync(p, JSON.stringify({ + profiles: [ + { id: 'good', name: 'Good', env: {} }, + { id: 'bad spaces', name: 'B', env: {} }, + { id: 'also-good', name: 'Also', env: { K: 'v' } }, + ], + defaultProfileId: 'good', + })); + const loaded = profiles.loadProfiles(); + assert.strictEqual(loaded.profiles.length, 2); + assert.deepStrictEqual(loaded.profiles.map(x => x.id), ['good', 'also-good']); + assert.strictEqual(loaded.defaultProfileId, 'good'); + } finally { + profiles.setProfilesPathForTesting(null); + try { fs.unlinkSync(p); } catch {} + } +}); + +test('loadProfiles drops dangling defaultProfileId', () => { + const p = tmpFile(); + profiles.setProfilesPathForTesting(p); + try { + fs.writeFileSync(p, JSON.stringify({ + profiles: [{ id: 'good', name: 'Good', env: {} }], + defaultProfileId: 'does-not-exist', + })); + const loaded = profiles.loadProfiles(); + assert.strictEqual(loaded.defaultProfileId, null); + } finally { + profiles.setProfilesPathForTesting(null); + try { fs.unlinkSync(p); } catch {} + } +}); + +test('loadProfiles handles malformed JSON gracefully', () => { + const p = tmpFile(); + profiles.setProfilesPathForTesting(p); + try { + fs.writeFileSync(p, 'not json{{{'); + const loaded = profiles.loadProfiles(); + assert.deepStrictEqual(loaded, { profiles: [], defaultProfileId: null }); + } finally { + profiles.setProfilesPathForTesting(null); + try { fs.unlinkSync(p); } catch {} + } +}); + +test('pickProfileForSession: explicit id wins, "none" returns null, undefined uses default', () => { + const p = tmpFile(); + profiles.setProfilesPathForTesting(p); + try { + profiles.saveProfiles({ + profiles: [ + { id: 'ds', name: 'DeepSeek', env: { ANTHROPIC_BASE_URL: 'https://ds' } }, + { id: 'glm', name: 'GLM', env: { ANTHROPIC_BASE_URL: 'https://glm' } }, + ], + defaultProfileId: 'ds', + }); + assert.strictEqual(profiles.pickProfileForSession('glm').id, 'glm'); + assert.strictEqual(profiles.pickProfileForSession(undefined).id, 'ds'); + assert.strictEqual(profiles.pickProfileForSession('none'), null); + assert.strictEqual(profiles.pickProfileForSession('does-not-exist'), null); + } finally { + profiles.setProfilesPathForTesting(null); + try { fs.unlinkSync(p); } catch {} + } +}); + +test('ENV_REF_RE matches single $VAR and ${VAR} only', () => { + assert.match('$FOO', profiles.ENV_REF_RE); + assert.match('${FOO}', profiles.ENV_REF_RE); + assert.match('$FOO_BAR_99', profiles.ENV_REF_RE); + assert.doesNotMatch('foo$BAR', profiles.ENV_REF_RE); + assert.doesNotMatch('$FOO bar', profiles.ENV_REF_RE); + assert.doesNotMatch('$', profiles.ENV_REF_RE); + assert.doesNotMatch('$1FOO', profiles.ENV_REF_RE); +});