diff --git a/src/features/instance/applications/components/ApplicationsSidebar/FileTypeIcon.tsx b/src/features/instance/applications/components/ApplicationsSidebar/FileTypeIcon.tsx
index b6101d708..9422a6c58 100644
--- a/src/features/instance/applications/components/ApplicationsSidebar/FileTypeIcon.tsx
+++ b/src/features/instance/applications/components/ApplicationsSidebar/FileTypeIcon.tsx
@@ -1,15 +1,22 @@
+import { isEnvFile } from '@/lib/env/envFile';
import {
CodeIcon,
FileCode2Icon,
FileIcon,
FileImageIcon,
FileJsonIcon,
+ FileKeyIcon,
FileTypeIcon as LucideFileTypeIcon,
} from 'lucide-react';
-export function FileTypeIcon({ extension }: { readonly extension: string | null }) {
+export function FileTypeIcon({ extension, filename }: { readonly extension: string | null; filename?: string }) {
const iconClassName = 'w-4 h-4 mr-2 shrink-0';
+ // `.env` files are matched by full name, not extension (`.env.local`'s "extension" is `local`).
+ if (isEnvFile(filename)) {
+ return ;
+ }
+
switch (extension) {
case 'js':
case 'jsx':
diff --git a/src/features/instance/applications/components/ApplicationsSidebar/ItemTitle.tsx b/src/features/instance/applications/components/ApplicationsSidebar/ItemTitle.tsx
index 4b77cfba9..3d74e8bcc 100644
--- a/src/features/instance/applications/components/ApplicationsSidebar/ItemTitle.tsx
+++ b/src/features/instance/applications/components/ApplicationsSidebar/ItemTitle.tsx
@@ -28,7 +28,7 @@ export function ItemTitle({ title, item, context }: {
pkg={!!item.data?.package || item.data.path === importedApplications}
/>
)
- : }
+ : }
{title}{content ? '*' : ''}
{item.data?.package && }
>
diff --git a/src/features/instance/applications/components/ContentViewer.tsx b/src/features/instance/applications/components/ContentViewer.tsx
index 27f0e4de3..b875da681 100644
--- a/src/features/instance/applications/components/ContentViewer.tsx
+++ b/src/features/instance/applications/components/ContentViewer.tsx
@@ -3,6 +3,7 @@ import { isDirectory } from '@/features/instance/applications/context/isDirector
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
import { useReadMeUrlTransformer } from '@/features/instance/applications/lib/readMeUrlTransform';
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
+import { isProtectedEnvFile } from '@/lib/env/envFile';
import { isBinaryFile } from '@/lib/string/binaryFileType';
import { getMediaFileType, MediaFileType } from '@/lib/string/mediaFileType';
import { CopyIcon, FileArchive } from 'lucide-react';
@@ -11,6 +12,7 @@ import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { newApplication } from './ApplicationsSidebar/specialItems';
import { DirectoryPlaceholder } from './DirectoryPlaceholder';
+import { EnvEditorView } from './EnvEditorView';
import { NewApplication } from './NewApplication';
import { TextEditorView } from './TextEditorView';
import './directoryReadMe.css';
@@ -56,6 +58,15 @@ export function ContentViewer() {
return ;
}
+ // Secret-bearing `.env` files get a managed secrets panel instead of the raw
+ // text editor, so values aren't flashed on screen or clobbered by accident.
+ // Template files (`.env.example` etc.) hold placeholders, not secrets, and
+ // fall through to the text editor. Keyed by path so per-file state (selection,
+ // raw-editor toggle) resets when switching files.
+ if (isProtectedEnvFile(openedEntry?.name)) {
+ return ;
+ }
+
return ;
}
diff --git a/src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx b/src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx
new file mode 100644
index 000000000..1bc7a5b8d
--- /dev/null
+++ b/src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx
@@ -0,0 +1,281 @@
+/**
+ * @vitest-environment jsdom
+ */
+import type { FileEntry } from '@/features/instance/applications/context/fileEntry';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import type { AxiosInstance } from 'axios';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+import { EnvEditorView } from './index';
+
+/*
+ The view is exercised against a mocked per-instance operations client, so both Harper behaviors
+ are covered: older versions returning the raw `.env` text (plaintext mode: click-to-reveal,
+ whole-file writes) and >= 5.2 returning `protected: true` (protected mode: key-level operations,
+ nothing revealable).
+ */
+
+const openedEntry: FileEntry = {
+ name: '.env',
+ path: 'myapp/.env',
+ project: 'myapp',
+};
+
+const post = vi.fn();
+const instanceClient = { post } as unknown as AxiosInstance;
+
+vi.mock('@/config/useInstanceClient', () => ({
+ useInstanceClientIdParams: () => ({ instanceClient, entityId: 'test-instance', entityType: 'instance' }),
+}));
+
+vi.mock('@/features/instance/applications/hooks/useEditorView', () => ({
+ useEditorView: () => ({ openedEntry }),
+}));
+
+// Avoids the router dependency of the real sessionStorage-backed hook; the raw-buffer
+// interplay it powers isn't under test here.
+const setContent = vi.fn();
+vi.mock('@/features/instance/applications/context/editorFileContent', () => ({
+ useEditorFileContent: () => ({ content: undefined, setContent }),
+}));
+
+vi.mock('@/hooks/usePermissions', () => ({
+ useInstanceBrowseManagePermission: () => true,
+}));
+
+// The raw-text escape hatch mounts Monaco; a marker div is enough to assert the handoff.
+vi.mock('@/features/instance/applications/components/TextEditorView', () => ({
+ TextEditorView: () =>
,
+}));
+
+// Radix dialogs rely on a couple of DOM APIs jsdom doesn't implement.
+beforeAll(() => {
+ Element.prototype.hasPointerCapture ??= () => false;
+ Element.prototype.setPointerCapture ??= () => undefined;
+ Element.prototype.releasePointerCapture ??= () => undefined;
+ Element.prototype.scrollIntoView ??= () => undefined;
+ if (typeof window.PointerEvent === 'undefined') {
+ window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent;
+ }
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+const PLAINTEXT_FILE = '# note\nAPI_KEY=secret123\nDB_URL=postgres://x\n';
+
+/** Routes mocked operation calls; returns the calls recorded for later assertions. */
+function mockOperations({ fileResponse }: { fileResponse: Record }) {
+ post.mockImplementation((_url: string, body: { operation: string; key?: string }) => {
+ switch (body.operation) {
+ case 'get_component_file':
+ return Promise.resolve({ data: fileResponse });
+ case 'set_component_file':
+ return Promise.resolve({ data: { message: 'ok' } });
+ case 'set_env_value':
+ return Promise.resolve({ data: { message: 'ok', keys: ['API_KEY', 'DB_URL', body.key] } });
+ case 'delete_env_value':
+ return Promise.resolve({ data: { message: 'ok', keys: ['DB_URL'] } });
+ default:
+ return Promise.reject(new Error(`Unexpected operation ${body.operation}`));
+ }
+ });
+}
+
+function renderView() {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+function operationCalls(operation: string) {
+ return post.mock.calls.filter(([, body]) => body.operation === operation).map(([, body]) => body);
+}
+
+describe('EnvEditorView (plaintext mode — Harper < 5.2 returns raw text)', () => {
+ function mockPlaintext() {
+ mockOperations({
+ fileResponse: { message: PLAINTEXT_FILE, size: PLAINTEXT_FILE.length, mtime: 'x', birthtime: 'x' },
+ });
+ }
+
+ it('lists keys with masked values and reveals one only on demand', async () => {
+ mockPlaintext();
+ renderView();
+
+ expect(await screen.findByText('API_KEY')).toBeTruthy();
+ expect(screen.getByText('DB_URL')).toBeTruthy();
+ // No value is in the document until deliberately revealed.
+ expect(screen.queryByText('secret123')).toBeNull();
+
+ fireEvent.click(screen.getAllByTitle('Reveal value')[0]);
+ expect(screen.getByText('secret123')).toBeTruthy();
+
+ // And it can be hidden again.
+ fireEvent.click(screen.getByTitle('Hide value'));
+ expect(screen.queryByText('secret123')).toBeNull();
+ });
+
+ it('adds a secret by rewriting the whole file, preserving comments and other keys', async () => {
+ mockPlaintext();
+ renderView();
+ await screen.findByText('API_KEY');
+
+ fireEvent.click(screen.getByRole('button', { name: /add/i }));
+ fireEvent.change(await screen.findByLabelText('Key'), { target: { value: 'NEW_KEY' } });
+ fireEvent.change(screen.getByLabelText('Value'), { target: { value: 'two words' } });
+
+ const submit = screen.getByRole('button', { name: /add secret/i });
+ await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false));
+ fireEvent.click(submit);
+
+ await waitFor(() => expect(operationCalls('set_component_file')).toHaveLength(1));
+ expect(operationCalls('set_component_file')[0]).toMatchObject({
+ project: 'myapp',
+ file: '.env',
+ payload: `# note\nAPI_KEY=secret123\nDB_URL=postgres://x\nNEW_KEY='two words'\n`,
+ });
+ });
+
+ it('edits a secret in place after click-to-reveal pre-fills the current value', async () => {
+ mockPlaintext();
+ renderView();
+
+ fireEvent.click(await screen.findByText('API_KEY'));
+ const revealButton = await screen.findByRole('button', { name: /reveal current value/i });
+ fireEvent.click(revealButton);
+
+ const textarea = screen.getByLabelText('Value') as HTMLTextAreaElement;
+ expect(textarea.value).toBe('secret123');
+ fireEvent.change(textarea, { target: { value: 'rotated' } });
+
+ const save = screen.getByRole('button', { name: /save/i });
+ await waitFor(() => expect(save.hasAttribute('disabled')).toBe(false));
+ fireEvent.click(save);
+
+ await waitFor(() => expect(operationCalls('set_component_file')).toHaveLength(1));
+ expect(operationCalls('set_component_file')[0]).toMatchObject({
+ payload: '# note\nAPI_KEY=rotated\nDB_URL=postgres://x\n',
+ });
+ });
+
+ it('deletes a secret through the edit dialog with a two-click confirm', async () => {
+ mockPlaintext();
+ renderView();
+
+ fireEvent.click(await screen.findByText('DB_URL'));
+ const deleteButton = await screen.findByRole('button', { name: /delete/i });
+ fireEvent.click(deleteButton);
+ // First click arms the confirmation; nothing is written yet.
+ expect(operationCalls('set_component_file')).toHaveLength(0);
+ fireEvent.click(screen.getByRole('button', { name: /confirm delete/i }));
+
+ await waitFor(() => expect(operationCalls('set_component_file')).toHaveLength(1));
+ expect(operationCalls('set_component_file')[0]).toMatchObject({
+ payload: '# note\nAPI_KEY=secret123\n',
+ });
+ });
+
+ it('offers the raw text editor as an escape hatch', async () => {
+ mockPlaintext();
+ renderView();
+ await screen.findByText('API_KEY');
+
+ fireEvent.click(screen.getByRole('button', { name: /edit as text/i }));
+ expect(screen.getByTestId('raw-text-editor')).toBeTruthy();
+
+ fireEvent.click(screen.getByRole('button', { name: /secrets view/i }));
+ expect(await screen.findByText('API_KEY')).toBeTruthy();
+ });
+});
+
+describe('EnvEditorView (protected mode — Harper >= 5.2 masks values)', () => {
+ const MASKED = 'API_KEY=********\nDB_URL=********\n';
+
+ function mockProtected() {
+ mockOperations({
+ fileResponse: {
+ protected: true,
+ keys: ['API_KEY', 'DB_URL'],
+ message: MASKED,
+ size: MASKED.length,
+ mtime: 'x',
+ birthtime: 'x',
+ },
+ });
+ }
+
+ it('lists key names with nothing revealable and no raw-text escape hatch', async () => {
+ mockProtected();
+ renderView();
+
+ expect(await screen.findByText('API_KEY')).toBeTruthy();
+ expect(screen.getByText('DB_URL')).toBeTruthy();
+ expect(screen.queryByTitle('Reveal value')).toBeNull();
+ expect(screen.queryByRole('button', { name: /edit as text/i })).toBeNull();
+ });
+
+ it('adds a secret through set_env_value and shows the returned key list', async () => {
+ mockProtected();
+ renderView();
+ await screen.findByText('API_KEY');
+
+ fireEvent.click(screen.getByRole('button', { name: /add/i }));
+ fireEvent.change(await screen.findByLabelText('Key'), { target: { value: 'NEW_KEY' } });
+ fireEvent.change(screen.getByLabelText('Value'), { target: { value: 's3cret' } });
+
+ const submit = screen.getByRole('button', { name: /add secret/i });
+ await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false));
+ fireEvent.click(submit);
+
+ await waitFor(() => expect(operationCalls('set_env_value')).toHaveLength(1));
+ expect(operationCalls('set_env_value')[0]).toMatchObject({
+ project: 'myapp',
+ file: '.env',
+ key: 'NEW_KEY',
+ value: 's3cret',
+ replicated: false,
+ });
+ // The key list from the operation response lands in the table without a refetch.
+ expect(await screen.findByText('NEW_KEY')).toBeTruthy();
+ expect(operationCalls('get_component_file')).toHaveLength(1);
+ // The whole-file write path is never used against a protected file.
+ expect(operationCalls('set_component_file')).toHaveLength(0);
+ });
+
+ it('replaces a value through set_env_value without offering a reveal', async () => {
+ mockProtected();
+ renderView();
+
+ fireEvent.click(await screen.findByText('API_KEY'));
+ await screen.findByRole('button', { name: /save/i });
+ expect(screen.queryByRole('button', { name: /reveal current value/i })).toBeNull();
+
+ fireEvent.change(screen.getByLabelText('New value'), { target: { value: 'rotated' } });
+ const save = screen.getByRole('button', { name: /save/i });
+ await waitFor(() => expect(save.hasAttribute('disabled')).toBe(false));
+ fireEvent.click(save);
+
+ await waitFor(() => expect(operationCalls('set_env_value')).toHaveLength(1));
+ expect(operationCalls('set_env_value')[0]).toMatchObject({ key: 'API_KEY', value: 'rotated' });
+ });
+
+ it('deletes a secret through delete_env_value and drops it from the table', async () => {
+ mockProtected();
+ renderView();
+
+ fireEvent.click(await screen.findByText('API_KEY'));
+ const deleteButton = await screen.findByRole('button', { name: /delete/i });
+ fireEvent.click(deleteButton);
+ fireEvent.click(screen.getByRole('button', { name: /confirm delete/i }));
+
+ await waitFor(() => expect(operationCalls('delete_env_value')).toHaveLength(1));
+ expect(operationCalls('delete_env_value')[0]).toMatchObject({ key: 'API_KEY', replicated: false });
+ await waitFor(() => expect(screen.queryByText('API_KEY')).toBeNull());
+ });
+});
diff --git a/src/features/instance/applications/components/EnvEditorView/index.tsx b/src/features/instance/applications/components/EnvEditorView/index.tsx
new file mode 100644
index 000000000..1b04f66db
--- /dev/null
+++ b/src/features/instance/applications/components/EnvEditorView/index.tsx
@@ -0,0 +1,185 @@
+/**
+ * A secrets-style editor for an application's `.env` files, replacing the raw text editor so
+ * values aren't flashed on screen (or clobbered) by accident. It adapts to what the connected
+ * Harper can do, duck-typed off the `get_component_file` response rather than a version check:
+ *
+ * - Older Harper returns the raw file text → keys and values are parsed client-side, values are
+ * masked behind a deliberate click-to-reveal, and every add/edit/delete rewrites the whole
+ * file through `set_component_file` (merge-preserving, comments intact). An "Edit as text"
+ * escape hatch keeps the raw Monaco editor available.
+ *
+ * - Harper >= 5.2 protects `.env` files (`protected: true`, masked message, key names only) →
+ * values can never be read back, so there is no reveal and no raw editing (saving the masked
+ * rendering would destroy the real values); adds/edits/deletes go through the key-level
+ * `set_env_value` / `delete_env_value` operations instead.
+ *
+ * Template files (`.env.example` etc.) never reach this view — they aren't secret and keep the
+ * plain text editor (see ContentViewer).
+ */
+import { Button } from '@/components/ui/button';
+import { useInstanceClientIdParams } from '@/config/useInstanceClient';
+import { useEditorFileContent } from '@/features/instance/applications/context/editorFileContent';
+import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
+import { SecretRow, SecretsManager } from '@/features/instance/secrets/SecretsManager';
+import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions';
+import { useDeleteEnvValue, useSetEnvValue } from '@/integrations/api/instance/applications/envValues';
+import {
+ getComponentFileQueryKey,
+ getComponentFileQueryOptions,
+ GetComponentFileResponse,
+} from '@/integrations/api/instance/applications/getComponentFile';
+import { useSetComponentFile } from '@/integrations/api/instance/applications/setComponentFile';
+import { parseEnv, removeEnvKeys, renderMaskedEnv, upsertEnvValues } from '@/lib/env/envFile';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { CodeIcon, ShieldCheckIcon, Table2Icon } from 'lucide-react';
+import { useCallback, useMemo, useState } from 'react';
+import { TextEditorView } from '../TextEditorView';
+
+export function EnvEditorView() {
+ const instanceParams = useInstanceClientIdParams();
+ const queryClient = useQueryClient();
+ const { openedEntry } = useEditorView();
+ const canManageBrowseInstance = useInstanceBrowseManagePermission();
+
+ const path = openedEntry?.path ?? '';
+ const project = openedEntry?.project ?? '';
+ const file = path.split('/').slice(1).join('/');
+ const fileQueryKey = getComponentFileQueryKey({ file, project, ...instanceParams });
+ const { data, isFetching, refetch } = useQuery(
+ getComponentFileQueryOptions({ file, project, ...instanceParams }),
+ );
+
+ // Unsaved edits from the raw text view. The secrets table is a GUI over the same buffer the
+ // text editor shows, so it parses (and writes back) the dirty content when there is any.
+ const { content: updatedFileContent, setContent } = useEditorFileContent(
+ !!openedEntry && !openedEntry.package && path,
+ );
+
+ const isProtected = data?.protected === true;
+ const rawText = updatedFileContent ?? data?.message;
+
+ const rows = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+ if (isProtected) {
+ return (data.keys ?? []).map((name) => ({ name }));
+ }
+ return Object.entries(parseEnv(rawText ?? '')).map(([name, value]) => ({ name, value }));
+ }, [data, isProtected, rawText]);
+
+ const canManage = !!openedEntry && !openedEntry.package && canManageBrowseInstance;
+
+ /*
+ Persistence, per mode. Plaintext-mode writes go through the ordinary whole-file save and then
+ sync the query cache (the EditorViewProvider derives the opened contents from it). Protected-
+ mode writes go through the key-level operations, whose responses carry the resulting key list.
+ */
+ const { mutateAsync: writeComponentFile } = useSetComponentFile();
+ const { mutateAsync: setEnvValueAsync } = useSetEnvValue();
+ const { mutateAsync: deleteEnvValueAsync } = useDeleteEnvValue();
+
+ const writeWholeFile = useCallback(async (next: string) => {
+ await writeComponentFile({ ...instanceParams, project, file, payload: next });
+ queryClient.setQueryData(
+ fileQueryKey,
+ (old) => old && { ...old, message: next },
+ );
+ setContent(undefined); // the raw buffer (dirty edits included) is now saved
+ }, [writeComponentFile, instanceParams, project, file, queryClient, fileQueryKey, setContent]);
+
+ const applyProtectedKeys = useCallback((keys: string[]) => {
+ queryClient.setQueryData(
+ fileQueryKey,
+ (old) => old && { ...old, keys, message: renderMaskedEnv(keys) },
+ );
+ }, [queryClient, fileQueryKey]);
+
+ const onSet = useCallback(async (key: string, value: string) => {
+ if (isProtected) {
+ const response = await setEnvValueAsync({ ...instanceParams, project, file, key, value });
+ applyProtectedKeys(response.keys);
+ } else {
+ await writeWholeFile(upsertEnvValues(rawText ?? '', { [key]: value }));
+ }
+ }, [isProtected, setEnvValueAsync, instanceParams, project, file, applyProtectedKeys, writeWholeFile, rawText]);
+
+ const onDelete = useCallback(async (key: string) => {
+ if (isProtected) {
+ const response = await deleteEnvValueAsync({ ...instanceParams, project, file, key });
+ applyProtectedKeys(response.keys);
+ } else {
+ await writeWholeFile(removeEnvKeys(rawText ?? '', key));
+ }
+ }, [isProtected, deleteEnvValueAsync, instanceParams, project, file, applyProtectedKeys, writeWholeFile, rawText]);
+
+ const [selectedName, setSelectedName] = useState(undefined);
+ const onRefresh = useCallback(() => refetch(), [refetch]);
+
+ // The raw-text escape hatch, for plaintext mode only: on a protected file the editor would
+ // show the masked rendering, and saving that back would overwrite the real values.
+ const [showRawEditor, setShowRawEditor] = useState(false);
+ if (showRawEditor && !isProtected) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+
+
+
{openedEntry?.name}
+
+ {isProtected
+ ? (
+ <>
+
+ This Harper version protects environment secrets: values stay on the instance and are edited by key —
+ they can be replaced or removed, but never read back.
+ >
+ )
+ : 'Values are masked to prevent accidental disclosure. Revealing one is a deliberate action.'}
+