Skip to content

Commit e62c621

Browse files
gnoviawanclaude
andauthored
feat: project env runtime support (#68)
* chore: start issue 41 env runtime support Open a draft branch for the project-specific environment variable runtime support work before implementation begins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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> * fix: address code review findings for env var runtime support - Fix race condition in handleImportEnvFile by capturing project ID and using functional state updates - Make inheritedEnv parameter required in resolveEnvForSpawn and add TODO for fetching system env from backend - Use hasProjectEnv flag to conditionally pass env to terminal spawn - Add env notice check in ConnectedTerminal for external terminal ID path - Add TODO comments about secure storage for secret env var values Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 80fe705 commit e62c621

File tree

11 files changed

+613
-12
lines changed

11 files changed

+613
-12
lines changed

src/renderer/components/terminal/ConnectedTerminal.tsx

Lines changed: 12 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) {
@@ -646,6 +651,12 @@ function ConnectedTerminalComponent({
646651
if (initialScrollback && initialScrollback.length > 0) {
647652
restoreScrollback(terminal, initialScrollback)
648653
}
654+
// Write one-time info line if project env vars were applied
655+
// (env should be passed via spawnOptions by the caller if this terminal was spawned with env vars)
656+
if (memoizedSpawnOptions?.env && Object.keys(memoizedSpawnOptions.env).length > 0) {
657+
const envCount = Object.keys(memoizedSpawnOptions.env).length
658+
terminal.write(`\x1b[36m\r\n[Project env: ${envCount} variable${envCount !== 1 ? 's' : ''} applied]\x1b[0m\r\n`)
659+
}
649660
// Restore scroll position if cached from previous pane
650661
restoreScrollPosition(externalTerminalId, terminal)
651662
if (onBoundToStoreTerminalRef.current) {
@@ -667,7 +678,7 @@ function ConnectedTerminalComponent({
667678
const terminalId = ptyIdRef.current || externalTerminalId
668679
if (terminalId && terminalRef.current) {
669680
captureScrollPosition(terminalId)
670-
void removeRendererRef(terminalId, instanceIdRef.current)
681+
void removeRendererRef(terminalId, instanceId)
671682
}
672683

673684
// Unregister terminal from registry

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ 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+
// TODO: Secret values (isSecret===true) should be stored in secure OS storage (keyring/secureStore)
18+
// instead of plaintext. For now, we persist all values but this is a security concern.
19+
// A future PR should implement secure storage for secrets.
20+
envVars: project.envVars?.map((envVar) => ({
21+
key: envVar.key,
22+
value: envVar.value,
23+
isSecret: envVar.isSecret
24+
}))
1725
}
1826
}
1927

@@ -25,7 +33,12 @@ function fromPersistedProject(persisted: PersistedProject): Project {
2533
path: persisted.path,
2634
isArchived: persisted.isArchived,
2735
gitBranch: persisted.gitBranch,
28-
defaultShell: persisted.defaultShell
36+
defaultShell: persisted.defaultShell,
37+
envVars: persisted.envVars?.map((envVar) => ({
38+
key: envVar.key,
39+
value: envVar.value,
40+
isSecret: envVar.isSecret
41+
}))
2942
}
3043
}
3144

src/renderer/hooks/use-snapshots.ts

Lines changed: 9 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,12 @@ 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+
// TODO: Pass actual system env from backend for variable expansion
118+
const { env, hasProjectEnv } = resolveEnvForSpawn(project?.envVars, {})
112119

113120
// Close all existing terminals for this project
114121
// CRITICAL: Kill PTYs before removing from store to prevent orphaned processes
@@ -131,7 +138,8 @@ async function restoreFromSnapshot(projectId: string, snapshot: PersistedSnapsho
131138
for (const persistedTerminal of snapshot.terminals) {
132139
const spawnResult = await terminalApi.spawn({
133140
shell: persistedTerminal.shell as 'powershell' | 'cmd' | 'bash' | 'zsh' | 'fish' | undefined,
134-
cwd: persistedTerminal.cwd
141+
cwd: persistedTerminal.cwd,
142+
...(hasProjectEnv ? { env } : {})
135143
})
136144

137145
if (!spawnResult.success) {

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

Lines changed: 16 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,12 @@ 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+
// TODO: Pass actual system env from backend for variable expansion
502+
const { env, hasProjectEnv } = resolveEnvForSpawn(project?.envVars, {})
496503

497504
debugLog('restoreFromLayout', `START [${restoreId}] ACQUIRING LOCK`, {
498505
projectId,
@@ -517,7 +524,7 @@ async function restoreFromLayout(
517524
// Map old IDs to new IDs for active terminal selection and pane remapping
518525
const idMap = new Map<string, string>()
519526

520-
for (const persistedTerminal of layout.terminals) {
527+
for (const persistedTerminal of layout.terminals) {
521528
if (isCancelled()) {
522529
await cleanupSpawnedPtys(newTerminals, restoreId, 'during restore loop')
523530
debugLog('restoreFromLayout', `CANCELLED [${restoreId}] during restore loop`)
@@ -548,7 +555,8 @@ async function restoreFromLayout(
548555
const normalizedShell = normalizeShellForStartup(resolvedShell)
549556
const spawnResult = await terminalApi.spawn({
550557
shell: normalizedShell,
551-
cwd: persistedTerminal.cwd
558+
cwd: persistedTerminal.cwd,
559+
...(hasProjectEnv ? { env } : {})
552560
})
553561

554562
debugLog('restoreFromLayout', `Spawn result [${terminalCallId}]`, {
@@ -706,14 +714,19 @@ async function createDefaultTerminal(
706714

707715
const shell = normalizeShellForStartup(resolvedShell)
708716

717+
// Resolve project env vars for spawn
718+
// TODO: Pass actual system env from backend for variable expansion
719+
const { env, hasProjectEnv } = resolveEnvForSpawn(project?.envVars, {})
720+
709721
debugLog('createDefaultTerminal', `Spawning default terminal [${defaultId}]`, {
710722
shell,
711723
cwd: project?.path
712724
})
713725

714726
const spawnResult = await terminalApi.spawn({
715727
shell,
716-
cwd: project?.path
728+
cwd: project?.path,
729+
...(hasProjectEnv ? { env } : {})
717730
})
718731

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

src/renderer/layouts/WorkspaceLayout.tsx

Lines changed: 11 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,15 @@ 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+
// TODO: Pass actual system env from backend for variable expansion
359+
const { env, hasProjectEnv } = resolveEnvForSpawn(activeProject?.envVars, {})
360+
361+
const spawnResult = await terminalApi.spawn({
362+
shell,
363+
cwd,
364+
...(hasProjectEnv ? { env } : {})
365+
})
357366
if (!spawnResult.success) {
358367
toast.error(spawnResult.error || 'Failed to create terminal')
359368
return
@@ -368,7 +377,7 @@ export default function WorkspaceLayout(): React.JSX.Element {
368377
terminalId: terminal.id
369378
})
370379
},
371-
[activeProject?.defaultShell, activeProject?.path, activeProjectId, addTerminal, appDefaultShell, maxTerminals, terminals.length]
380+
[activeProject?.defaultShell, activeProject?.path, activeProject?.envVars, activeProjectId, addTerminal, appDefaultShell, maxTerminals, terminals.length]
372381
)
373382

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