Skip to content

Commit 79e844f

Browse files
gnoviawanclaude
andcommitted
feat: add project-specific environment variable runtime support (#68)
Implements Issue #41 - Project-specific environment variable runtime support Changes: - Add PersistedEnvVariable type and envVars field to PersistedProject - Add .env file parsing utility with parseEnvFile and mergeEnvVars functions - Add env-resolution helper for terminal spawn with variable expansion - Extend DialogApi with selectFile method for .env file selection - Update ProjectSettings to support .env import with error handling - Pass resolved env vars through all terminal spawn paths: - WorkspaceLayout handleCreateTerminalInPane - use-terminal-restore restoreFromLayout and createDefaultTerminal - use-snapshots restoreFromSnapshot - Print one-time terminal info line when env vars are applied - Add comprehensive test coverage for env-parser utility Technical details: - Supports Unix ($VAR, ${VAR}) and Windows (%VAR%) variable references - Only expands against inherited system/process env, not other project vars - Normalizes env vars on save (trims keys, filters empty) - Shows warnings for invalid lines during .env import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 63c2a75 commit 79e844f

File tree

11 files changed

+579
-11
lines changed

11 files changed

+579
-11
lines changed

src/renderer/components/terminal/ConnectedTerminal.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,11 @@ function ConnectedTerminalComponent({
614614
if (initialScrollback && initialScrollback.length > 0) {
615615
restoreScrollback(terminal, initialScrollback)
616616
}
617+
// Write one-time info line if project env vars were applied
618+
if (memoizedSpawnOptions?.env && Object.keys(memoizedSpawnOptions.env).length > 0) {
619+
const envCount = Object.keys(memoizedSpawnOptions.env).length
620+
terminal.write(`\x1b[36m\r\n[Project env: ${envCount} variable${envCount !== 1 ? 's' : ''} applied]\x1b[0m\r\n`)
621+
}
617622
// Restore scroll position if cached from previous pane
618623
restoreScrollPosition(result.data.id, terminal)
619624
if (onSpawned) {
@@ -667,7 +672,7 @@ function ConnectedTerminalComponent({
667672
const terminalId = ptyIdRef.current || externalTerminalId
668673
if (terminalId && terminalRef.current) {
669674
captureScrollPosition(terminalId)
670-
void removeRendererRef(terminalId, instanceIdRef.current)
675+
void removeRendererRef(terminalId, instanceId)
671676
}
672677

673678
// Unregister terminal from registry

src/renderer/hooks/use-projects-persistence.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ function toPersistedProject(project: Project): PersistedProject {
1313
path: project.path,
1414
isArchived: project.isArchived,
1515
gitBranch: project.gitBranch,
16-
defaultShell: project.defaultShell
16+
defaultShell: project.defaultShell,
17+
envVars: project.envVars?.map((envVar) => ({
18+
key: envVar.key,
19+
value: envVar.value,
20+
isSecret: envVar.isSecret
21+
}))
1722
}
1823
}
1924

@@ -25,7 +30,12 @@ function fromPersistedProject(persisted: PersistedProject): Project {
2530
path: persisted.path,
2631
isArchived: persisted.isArchived,
2732
gitBranch: persisted.gitBranch,
28-
defaultShell: persisted.defaultShell
33+
defaultShell: persisted.defaultShell,
34+
envVars: persisted.envVars?.map((envVar) => ({
35+
key: envVar.key,
36+
value: envVar.value,
37+
isSecret: envVar.isSecret
38+
}))
2939
}
3040
}
3141

src/renderer/hooks/use-snapshots.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { persistenceApi, terminalApi } from '@/lib/api'
44
import { useTerminalStore } from '@/stores/terminal-store'
55
import { useProjectStore } from '@/stores/project-store'
66
import { getTerminal } from '@/utils/terminal-registry'
7+
import { resolveEnvForSpawn } from '@/lib/env-parser'
78
import type { PersistedTerminal, PersistedSnapshot } from '../../shared/types/persistence.types'
89
import { DEFAULT_SCROLLBACK_LIMIT } from '../../shared/types/persistence.types'
910
import type { Snapshot } from '@/types/project'
@@ -109,6 +110,11 @@ export function useRestoreSnapshot(): (snapshotId: string) => Promise<void> {
109110
*/
110111
async function restoreFromSnapshot(projectId: string, snapshot: PersistedSnapshot): Promise<void> {
111112
const terminalStore = useTerminalStore.getState()
113+
const projectStore = useProjectStore.getState()
114+
const project = projectStore.projects.find((p) => p.id === projectId)
115+
116+
// Resolve project env vars for spawn
117+
const { env } = resolveEnvForSpawn(project?.envVars)
112118

113119
// Close all existing terminals for this project
114120
// CRITICAL: Kill PTYs before removing from store to prevent orphaned processes
@@ -131,7 +137,8 @@ async function restoreFromSnapshot(projectId: string, snapshot: PersistedSnapsho
131137
for (const persistedTerminal of snapshot.terminals) {
132138
const spawnResult = await terminalApi.spawn({
133139
shell: persistedTerminal.shell as 'powershell' | 'cmd' | 'bash' | 'zsh' | 'fish' | undefined,
134-
cwd: persistedTerminal.cwd
140+
cwd: persistedTerminal.cwd,
141+
env
135142
})
136143

137144
if (!spawnResult.success) {

src/renderer/hooks/use-terminal-restore.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useAppSettingsStore } from '../stores/app-settings-store'
55
import { useWorkspaceStore } from '../stores/workspace-store'
66
import { terminalApi } from '@/lib/api'
77
import { shellApi } from '@/lib/shell-api'
8+
import { resolveEnvForSpawn } from '@/lib/env-parser'
89
import {
910
loadPersistedTerminals,
1011
saveTerminalLayout,
@@ -415,6 +416,11 @@ async function restoreFromLayout(projectId: string, layout: PersistedTerminalLay
415416

416417
try {
417418
const terminalStore = useTerminalStore.getState()
419+
const projectStore = useProjectStore.getState()
420+
const project = projectStore.projects.find((p) => p.id === projectId)
421+
422+
// Resolve project env vars for spawn
423+
const { env } = resolveEnvForSpawn(project?.envVars)
418424

419425
debugLog('restoreFromLayout', `START [${restoreId}] ACQUIRING LOCK`, {
420426
projectId,
@@ -458,7 +464,8 @@ async function restoreFromLayout(projectId: string, layout: PersistedTerminalLay
458464
const normalizedShell = normalizeShellForStartup(resolvedShell)
459465
const spawnResult = await terminalApi.spawn({
460466
shell: normalizedShell,
461-
cwd: persistedTerminal.cwd
467+
cwd: persistedTerminal.cwd,
468+
env
462469
})
463470

464471
debugLog('restoreFromLayout', `Spawn result [${terminalCallId}]`, {
@@ -592,14 +599,18 @@ async function createDefaultTerminal(projectId: string): Promise<void> {
592599
const resolvedShell = await resolveShellToPath(shellSetting)
593600
const shell = normalizeShellForStartup(resolvedShell)
594601

602+
// Resolve project env vars for spawn
603+
const { env } = resolveEnvForSpawn(project?.envVars)
604+
595605
debugLog('createDefaultTerminal', `Spawning default terminal [${defaultId}]`, {
596606
shell,
597607
cwd: project?.path
598608
})
599609

600610
const spawnResult = await terminalApi.spawn({
601611
shell,
602-
cwd: project?.path
612+
cwd: project?.path,
613+
env
603614
})
604615

605616
debugLog('createDefaultTerminal', `Spawn result [${defaultId}]`, {

src/renderer/layouts/WorkspaceLayout.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import { useEditorPersistence } from '@/hooks/use-editor-persistence'
6464
import { DEFAULT_APP_SETTINGS } from '@/types/settings'
6565
import { toast } from 'sonner'
6666
import { TitleBar } from '@/components/TitleBar'
67+
import { resolveEnvForSpawn } from '@/lib/env-parser'
6768

6869
export default function WorkspaceLayout(): React.JSX.Element {
6970
const location = useLocation()
@@ -313,7 +314,10 @@ export default function WorkspaceLayout(): React.JSX.Element {
313314
const shell = shellName || activeProject?.defaultShell || appDefaultShell || undefined
314315
const cwd = activeProject?.path
315316

316-
const spawnResult = await terminalApi.spawn({ shell, cwd })
317+
// Resolve project env vars for spawn
318+
const { env } = resolveEnvForSpawn(activeProject?.envVars)
319+
320+
const spawnResult = await terminalApi.spawn({ shell, cwd, env })
317321
if (!spawnResult.success) {
318322
toast.error(spawnResult.error || 'Failed to create terminal')
319323
return
@@ -328,7 +332,7 @@ export default function WorkspaceLayout(): React.JSX.Element {
328332
terminalId: terminal.id
329333
})
330334
},
331-
[activeProject?.defaultShell, activeProject?.path, activeProjectId, addTerminal, appDefaultShell, maxTerminals, terminals.length]
335+
[activeProject?.defaultShell, activeProject?.path, activeProject?.envVars, activeProjectId, addTerminal, appDefaultShell, maxTerminals, terminals.length]
332336
)
333337

334338
const handleNewTerminal = useCallback(() => {

src/renderer/lib/dialog-api.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ function createTauriDialogApi(): DialogApi {
3131
} catch (err) {
3232
return { success: false, error: String(err), code: 'DIALOG_ERROR' }
3333
}
34+
},
35+
36+
async selectFile(options?: {
37+
filters?: Array<{ name: string; extensions: string[] }>
38+
title?: string
39+
}): Promise<IpcResult<string>> {
40+
try {
41+
const selected = await open({
42+
multiple: false,
43+
filters: options?.filters,
44+
title: options?.title || 'Select File'
45+
})
46+
if (!selected) {
47+
return { success: false, error: 'No file selected', code: 'CANCELLED' }
48+
}
49+
return { success: true, data: selected as string }
50+
} catch (err) {
51+
return { success: false, error: String(err), code: 'DIALOG_ERROR' }
52+
}
3453
}
3554
}
3655
}

0 commit comments

Comments
 (0)