Skip to content

Commit d1539d1

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 ac66d9d commit d1539d1

File tree

11 files changed

+580
-12
lines changed

11 files changed

+580
-12
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: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useAppSettingsStore } from '../stores/app-settings-store'
55
import { useWorkspaceStore, terminalTabId, findPaneContainingTab } 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,
@@ -493,6 +494,11 @@ async function restoreFromLayout(
493494
}
494495

495496
const terminalStore = useTerminalStore.getState()
497+
const projectStore = useProjectStore.getState()
498+
const project = projectStore.projects.find((p) => p.id === projectId)
499+
500+
// Resolve project env vars for spawn
501+
const { env } = resolveEnvForSpawn(project?.envVars)
496502

497503
debugLog('restoreFromLayout', `START [${restoreId}] ACQUIRING LOCK`, {
498504
projectId,
@@ -517,7 +523,7 @@ async function restoreFromLayout(
517523
// Map old IDs to new IDs for active terminal selection and pane remapping
518524
const idMap = new Map<string, string>()
519525

520-
for (const persistedTerminal of layout.terminals) {
526+
for (const persistedTerminal of layout.terminals) {
521527
if (isCancelled()) {
522528
await cleanupSpawnedPtys(newTerminals, restoreId, 'during restore loop')
523529
debugLog('restoreFromLayout', `CANCELLED [${restoreId}] during restore loop`)
@@ -548,7 +554,8 @@ async function restoreFromLayout(
548554
const normalizedShell = normalizeShellForStartup(resolvedShell)
549555
const spawnResult = await terminalApi.spawn({
550556
shell: normalizedShell,
551-
cwd: persistedTerminal.cwd
557+
cwd: persistedTerminal.cwd,
558+
env
552559
})
553560

554561
debugLog('restoreFromLayout', `Spawn result [${terminalCallId}]`, {
@@ -706,14 +713,18 @@ async function createDefaultTerminal(
706713

707714
const shell = normalizeShellForStartup(resolvedShell)
708715

716+
// Resolve project env vars for spawn
717+
const { env } = resolveEnvForSpawn(project?.envVars)
718+
709719
debugLog('createDefaultTerminal', `Spawning default terminal [${defaultId}]`, {
710720
shell,
711721
cwd: project?.path
712722
})
713723

714724
const spawnResult = await terminalApi.spawn({
715725
shell,
716-
cwd: project?.path
726+
cwd: project?.path,
727+
env
717728
})
718729

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

src/renderer/layouts/WorkspaceLayout.tsx

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

7172
export default function WorkspaceLayout(): React.JSX.Element {
7273
const location = useLocation()
@@ -353,7 +354,10 @@ export default function WorkspaceLayout(): React.JSX.Element {
353354
const shell = shellName || activeProject?.defaultShell || appDefaultShell || undefined
354355
const cwd = activeProject?.path
355356

356-
const spawnResult = await terminalApi.spawn({ shell, cwd })
357+
// Resolve project env vars for spawn
358+
const { env } = resolveEnvForSpawn(activeProject?.envVars)
359+
360+
const spawnResult = await terminalApi.spawn({ shell, cwd, env })
357361
if (!spawnResult.success) {
358362
toast.error(spawnResult.error || 'Failed to create terminal')
359363
return
@@ -368,7 +372,7 @@ export default function WorkspaceLayout(): React.JSX.Element {
368372
terminalId: terminal.id
369373
})
370374
},
371-
[activeProject?.defaultShell, activeProject?.path, activeProjectId, addTerminal, appDefaultShell, maxTerminals, terminals.length]
375+
[activeProject?.defaultShell, activeProject?.path, activeProject?.envVars, activeProjectId, addTerminal, appDefaultShell, maxTerminals, terminals.length]
372376
)
373377

374378
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)