mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
feat(docker): add a folder picker to both path fields
Both paths in the adoption form had to be typed. Each gets a Browse button using the same path-input-group markup Link Existing uses, so the two look and behave alike. What they can browse differs, and that is the point. The host workspace path reuses the existing host picker. The container workdir cannot: an adopted container has nothing mounted at a matching host path, so a host listing would be a different filesystem — and getting this field wrong is the source of the opaque OCI chdir error at launch, which makes it the field that most needs to be clickable. Adds a read-only POST /api/docker-cases/browse: one `ls` through docker exec, no writes, no lifecycle, path shell-escaped like every other value. `ls -Ap` marks directories with a trailing slash and keeps names with spaces intact. PathPicker takes an optional fetchListing source rather than being forked: the container variant only swaps where the rows come from, and reuses the rendering, navigation, Up and Choose/Select unchanged.
This commit is contained in:
@@ -2844,7 +2844,10 @@
|
||||
</div>
|
||||
<div class="form-row docker-adopt-only">
|
||||
<label>Container Workdir</label>
|
||||
<input type="text" id="dockerAdoptWorkdir" placeholder="/workspace" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="dockerAdoptWorkdir" placeholder="/workspace" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openDockerWorkdirPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint">A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -2854,7 +2857,10 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Workspace Path</label>
|
||||
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openDockerWorkspacePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint">Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
|
||||
@@ -185,9 +185,13 @@ const PathPicker = {
|
||||
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
if (this._showHidden) params.set('showHidden', 'true');
|
||||
try {
|
||||
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
|
||||
// A caller may supply its own source (the container-workdir picker browses
|
||||
// INSIDE a container, which the host filesystem endpoint cannot answer).
|
||||
// It returns the same shape, so everything below is unchanged.
|
||||
const result = this._options.fetchListing
|
||||
? await this._options.fetchListing(path)
|
||||
: await (await fetch(`/api/filesystem/browse?${params.toString()}`)).json();
|
||||
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
this.render(result.data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2895,6 +2895,62 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
/** HOST workspace directory — the same picker Link Existing uses. */
|
||||
openDockerWorkspacePathPicker() {
|
||||
const pathInput = document.getElementById('dockerWorkspacePath');
|
||||
PathPicker.open({
|
||||
title: 'Select Host Workspace Folder',
|
||||
initialPath: pathInput.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
const nameInput = document.getElementById('dockerCaseName');
|
||||
if (nameInput && !nameInput.value.trim()) {
|
||||
const folder = path.split('/').filter(Boolean).pop() || '';
|
||||
if (/^[a-zA-Z0-9_-]+$/.test(folder)) nameInput.value = folder;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Container workdir. Browses INSIDE the container, because for an adopted
|
||||
* container nothing is mounted at a matching host path — the host picker would
|
||||
* be listing a different filesystem, and typing this field blind is exactly
|
||||
* what makes the launch fail with an OCI chdir error.
|
||||
*/
|
||||
openDockerWorkdirPicker() {
|
||||
const pathInput = document.getElementById('dockerAdoptWorkdir');
|
||||
const container = document.getElementById('dockerContainerName')?.value.trim();
|
||||
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
|
||||
if (!container) {
|
||||
this.showToast('Enter the container name first', 'error');
|
||||
return;
|
||||
}
|
||||
PathPicker.open({
|
||||
title: `Select Folder Inside ${container}`,
|
||||
initialPath: pathInput.value.trim() || '/',
|
||||
directoriesOnly: true,
|
||||
fetchListing: async (path) => {
|
||||
const data = await this._apiJson('/api/docker-cases/browse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hostId, container, path: path || '/' }),
|
||||
});
|
||||
if (!data) return { success: false, error: `Could not read ${container}. Is it running?` };
|
||||
if (data.error) return { success: false, error: data.error };
|
||||
// Shape it like the host endpoint: one root, so Up/Location behave.
|
||||
return {
|
||||
success: true,
|
||||
data: { ...data, root: '/', roots: [{ label: container, path: '/' }], truncated: false },
|
||||
};
|
||||
},
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async linkRemoteCase() {
|
||||
const name = document.getElementById('remoteCaseName').value.trim();
|
||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
DockerCaseLinkSchema,
|
||||
DockerCaseAdoptSchema,
|
||||
DockerAdoptPreflightSchema,
|
||||
DockerBrowseSchema,
|
||||
DockerHostSchema,
|
||||
DockerExportSchema,
|
||||
DockerImportSchema,
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
dockerDisplayPath,
|
||||
probeAdoptableContainer,
|
||||
listDockerContainers,
|
||||
browseInContainer,
|
||||
DOCKER_ADOPT_PROBE_MODES,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
@@ -78,7 +80,7 @@ import {
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
} from '../../docker-hosts.js';
|
||||
import type { AdoptedContainerProbe, DockerContainerInfo } from '../../docker-hosts.js';
|
||||
import type { AdoptedContainerProbe, DockerBrowseResult, DockerContainerInfo } from '../../docker-hosts.js';
|
||||
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
@@ -895,6 +897,28 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Browse a directory INSIDE a container, for the adoption form's
|
||||
* container-workdir picker. The host picker cannot answer this: for an adopted
|
||||
* container nothing is mounted at a matching host path, so the field would
|
||||
* otherwise be typed blind. Read-only — one `ls` through `docker exec`.
|
||||
*/
|
||||
app.post('/api/docker-cases/browse', async (req): Promise<ApiResponse<DockerBrowseResult>> => {
|
||||
const body = parseBody(DockerBrowseSchema, req.body);
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const result = await browseInContainer(
|
||||
{
|
||||
engine: host.engine ?? 'docker',
|
||||
context: host.context,
|
||||
daemonHost: host.daemonHost,
|
||||
containerName: body.container,
|
||||
},
|
||||
body.path || '/'
|
||||
);
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
app.post('/api/docker-cases/adopt-preflight', async (req): Promise<ApiResponse<AdoptedContainerProbe>> => {
|
||||
const body = parseBody(DockerAdoptPreflightSchema, req.body);
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
|
||||
|
||||
@@ -844,6 +844,22 @@ export const DockerAdoptPreflightSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Read-only directory listing inside a container (adoption workdir picker). */
|
||||
export const DockerBrowseSchema = z.object({
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
container: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'),
|
||||
path: z
|
||||
.string()
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in path')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DockerExportSchema = z.object({
|
||||
mode: z.enum(['full', 'workspace']).optional(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user