diff --git a/src/components/ContainerFilesPanel.test.ts b/src/components/ContainerFilesPanel.test.ts
new file mode 100644
index 0000000..c5e0dbb
--- /dev/null
+++ b/src/components/ContainerFilesPanel.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import { createTestingPinia } from "@pinia/testing";
+import ContainerFilesPanel from "./ContainerFilesPanel.vue";
+import { containerFilesApi } from "@/services/api";
+
+vi.mock("@/services/api", () => ({
+ containerFilesApi: {
+ list: vi.fn(),
+ materialize: vi.fn(),
+ },
+}));
+
+// Shaped like what the agent returns, which mirrors a real `ls -lA` line from
+// nginx:alpine.
+const listing = {
+ data: {
+ path: "/etc/nginx",
+ service: "app",
+ files: [
+ { name: "conf.d", path: "/etc/nginx/conf.d", size: 4096, mode: "drwxr-xr-x", is_dir: true, is_symlink: false },
+ {
+ name: "nginx.conf",
+ path: "/etc/nginx/nginx.conf",
+ size: 648,
+ mode: "-rw-r--r--",
+ is_dir: false,
+ is_symlink: false,
+ modified_raw: "Jun 17 15:58",
+ },
+ ],
+ },
+};
+
+const mountTab = () =>
+ mount(ContainerFilesPanel, {
+ props: { deploymentName: "test-app", serviceNames: ["app", "db"] },
+ global: {
+ plugins: [createTestingPinia({ createSpy: vi.fn })],
+ stubs: { ConfirmModal: { template: "
", props: ["visible"] } },
+ },
+ });
+
+describe("ContainerFilesPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(containerFilesApi.list).mockResolvedValue(listing as any);
+ });
+
+ it("lists what the running service holds", async () => {
+ const wrapper = mountTab();
+ await flushPromises();
+
+ expect(containerFilesApi.list).toHaveBeenCalledWith("test-app", "app", "/");
+ expect(wrapper.text()).toContain("conf.d");
+ expect(wrapper.text()).toContain("nginx.conf");
+ });
+
+ it("navigates into a directory", async () => {
+ const wrapper = mountTab();
+ await flushPromises();
+
+ const confd = wrapper.findAll(".cf-name").find((b) => b.text().includes("conf.d"));
+ await confd!.trigger("click");
+ await flushPromises();
+
+ expect(containerFilesApi.list).toHaveBeenLastCalledWith("test-app", "app", "/etc/nginx/conf.d");
+ });
+
+ it("brings a path onto the host and reports where it landed", async () => {
+ vi.mocked(containerFilesApi.materialize).mockResolvedValue({
+ data: {
+ deployment: "test-app",
+ service: "app",
+ container_path: "/etc/nginx/nginx.conf",
+ host_path: "./nginx.conf",
+ },
+ } as any);
+
+ const wrapper = mountTab();
+ await flushPromises();
+
+ // The row action confirms first, so drive the confirmed path directly.
+ (wrapper.vm as any).pending = "/etc/nginx/nginx.conf";
+ await (wrapper.vm as any).materialize();
+ await flushPromises();
+
+ expect(containerFilesApi.materialize).toHaveBeenCalledWith("test-app", "app", {
+ container_path: "/etc/nginx/nginx.conf",
+ });
+ expect(wrapper.emitted("materialized")?.[0]).toEqual([
+ { hostPath: "./nginx.conf", containerPath: "/etc/nginx/nginx.conf" },
+ ]);
+ });
+
+ it("explains why an image without a shell cannot be browsed", async () => {
+ vi.mocked(containerFilesApi.list).mockRejectedValue({
+ response: {
+ data: {
+ error: "failed to list /: no shell",
+ hint: "the service must be running and its image must provide a shell to be browsed",
+ },
+ },
+ });
+
+ const wrapper = mountTab();
+ await flushPromises();
+
+ expect(wrapper.text()).toContain("no shell");
+ expect(wrapper.text()).toContain("must provide a shell");
+ });
+});
diff --git a/src/components/ContainerFilesPanel.vue b/src/components/ContainerFilesPanel.vue
new file mode 100644
index 0000000..a69d902
--- /dev/null
+++ b/src/components/ContainerFilesPanel.vue
@@ -0,0 +1,403 @@
+
+
+
+
+
+
+
+
+ Reading the container...
+
+
+
+
+
+
{{ error }}
+
{{ hint }}
+
+
+
+
+
+
Nothing here
+
This directory is empty.
+
+
+
+
+
+
+ | Name |
+ Size |
+ Modified |
+ Host |
+
+
+
+
+ |
+
+ |
+
+
+ |
+
+
+
+ {{ file.name }}
+ to {{ file.link_target }}
+
+ |
+ {{ file.is_dir ? "" : formatBytes(file.size) }} |
+ {{ file.modified_raw }} |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/FileBrowser.test.ts b/src/components/FileBrowser.test.ts
new file mode 100644
index 0000000..3c7a574
--- /dev/null
+++ b/src/components/FileBrowser.test.ts
@@ -0,0 +1,128 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import { createTestingPinia } from "@pinia/testing";
+import FileBrowser from "./FileBrowser.vue";
+import { deploymentsApi } from "@/services/api";
+import type { FileBrowserApi, FileInfo } from "@/services/api";
+import type { ComposeMount } from "@/utils/compose";
+
+vi.mock("@/services/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ deploymentsApi: { removeComposeMount: vi.fn() },
+ configApi: { get: vi.fn().mockResolvedValue({ data: {} }) },
+ };
+});
+
+const files: FileInfo[] = [
+ { name: "conf.d", path: "/conf.d", size: 4096, is_dir: true, mod_time: "2026-07-14T00:00:00Z", permissions: "755" },
+ {
+ name: "notes.txt",
+ path: "/notes.txt",
+ size: 12,
+ is_dir: false,
+ mod_time: "2026-07-14T00:00:00Z",
+ permissions: "644",
+ },
+];
+
+// Only conf.d is mounted, so the two rows should be treated differently.
+const mounts: ComposeMount[] = [{ service: "app", source: "./conf.d", target: "/etc/nginx/conf.d", readOnly: false }];
+
+const fakeApi = (): FileBrowserApi => ({
+ list: vi.fn().mockResolvedValue({ data: { files } }),
+ getInfo: vi.fn().mockResolvedValue({ data: { total_size: 0, file_count: 0 } }),
+ upload: vi.fn(),
+ download: vi.fn(),
+ createDir: vi.fn(),
+ createFile: vi.fn(),
+ chmod: vi.fn(),
+ delete: vi.fn().mockResolvedValue({}),
+ getContent: vi.fn().mockResolvedValue({ data: "" }),
+});
+
+const mountBrowser = () =>
+ mount(FileBrowser, {
+ props: { deploymentName: "test-app", api: fakeApi(), mounts, serviceNames: ["app"], enableMount: true },
+ global: { plugins: [createTestingPinia({ createSpy: vi.fn })] },
+ });
+
+describe("FileBrowser mounted paths", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("tags a path the container reads, and says where", async () => {
+ const wrapper = mountBrowser();
+ await flushPromises();
+
+ const tags = wrapper.findAll(".mount-tag");
+ expect(tags).toHaveLength(1);
+ expect(tags[0].attributes("title")).toContain("app reads this at /etc/nginx/conf.d");
+ });
+
+ it("offers Unmount only for a path that is mounted", async () => {
+ const wrapper = mountBrowser();
+ await flushPromises();
+
+ (wrapper.vm as any).toggleRowMenu("/conf.d");
+ await flushPromises();
+ expect(wrapper.text()).toContain("Unmount");
+
+ (wrapper.vm as any).toggleRowMenu("/notes.txt");
+ await flushPromises();
+ expect(wrapper.text()).not.toContain("Unmount");
+ });
+
+ it("unmounts every service reading the path, then reports it", async () => {
+ vi.mocked(deploymentsApi.removeComposeMount).mockResolvedValue({ data: {} } as any);
+
+ const wrapper = mountBrowser();
+ await flushPromises();
+
+ (wrapper.vm as any).fileToUnmount = files[0];
+ await (wrapper.vm as any).unmountFile();
+ await flushPromises();
+
+ expect(deploymentsApi.removeComposeMount).toHaveBeenCalledWith("test-app", {
+ source_path: "./conf.d",
+ target_path: "/etc/nginx/conf.d",
+ service_name: "app",
+ });
+ expect(wrapper.emitted("unmounted")).toBeTruthy();
+ });
+
+ // The modals are teleported to the body, so they are read from there.
+ it("warns that deleting a mounted path takes it from the container too", async () => {
+ const wrapper = mountBrowser();
+ await flushPromises();
+
+ (wrapper.vm as any).confirmDelete(files[0]);
+ await flushPromises();
+ expect(document.body.textContent).toContain("takes it from the running container too");
+
+ wrapper.unmount();
+ });
+
+ it("does not warn about the container when the path is not mounted", async () => {
+ const wrapper = mountBrowser();
+ await flushPromises();
+
+ (wrapper.vm as any).confirmDelete(files[1]);
+ await flushPromises();
+ expect(document.body.textContent).not.toContain("takes it from the running container too");
+
+ wrapper.unmount();
+ });
+
+ it("explains that an unmount leaves the host copy behind", async () => {
+ const wrapper = mountBrowser();
+ await flushPromises();
+
+ (wrapper.vm as any).confirmUnmount(files[0]);
+ await flushPromises();
+ expect(document.body.textContent).toContain("stays on the host");
+ expect(document.body.textContent).toContain("will not touch the container");
+
+ wrapper.unmount();
+ });
+});
diff --git a/src/components/FileBrowser.vue b/src/components/FileBrowser.vue
index cf5bea7..1e5b853 100644
--- a/src/components/FileBrowser.vue
+++ b/src/components/FileBrowser.vue
@@ -114,6 +114,10 @@
{{ file.name }}
+
+
+ Mounted
+
{{ file.child_count }} items
@@ -153,6 +157,14 @@
{{ fileMounts(file).length > 0 ? "Edit mount" : "Add compose mount" }}
+