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 @@ + + + + + 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" }} +
{{ file.name }}
+
+ + Mounted +
{{ file.is_dir @@ -218,6 +234,14 @@ {{ fileMounts(file).length > 0 ? "Edit mount" : "Add compose mount" }} +
+ +