Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,21 @@ Use the inline buttons on a service in the Projects tree to manage individual co
| **Open Container Shell** | Open a VS Code terminal connected to a running Linux Host container. |
| **Open in Browser** | Open a running container's web endpoint in your browser. |

## Deploy and Stop
## Configure, Deploy, and Stop

Deploy or stop a project on the selected target. A deploy operation builds container images, transfers them to the target, and starts services. You can trigger either operation from:
Configure a project's parameters, or deploy or stop it on the selected target. A deploy operation builds container images, transfers them to the target, and starts services. You can trigger these operations from:

- Running **Topo: Deploy** from the Command Palette, then selecting a `compose.yaml` file from the workspace.
- Right-clicking `compose.yaml` in the Explorer or editor tab and selecting **Topo Deploy** or **Topo Stop**.
- Using the inline **Deploy** or **Stop** buttons on a project in the **Projects** view.
- Right-clicking `compose.yaml` in the Explorer or editor tab and selecting **Topo Configure**, **Topo Deploy**, or **Topo Stop**.
- Using the inline **Configure**, **Deploy**, or **Stop** buttons on a project in the **Projects** view.

## Project Management

The **Projects** view appears in the **Topo** activity bar container and lists workspace projects discovered from top-level `compose.yaml` or `compose.yml` files.

When a target is selected, each project can expand to show deployed containers for that project, grouped by processing domain.

Use the inline buttons on a project row to deploy or stop that project's compose file on the selected target. These actions behave the same as running **Topo Deploy** or **Topo Stop** from the context menu of the project's compose file.
Use the inline buttons on a project row to configure, deploy, or stop that project's compose file. These actions behave the same as running **Topo Configure**, **Topo Deploy**, or **Topo Stop** from the context menu of the project's compose file.

### Initialize a Project

Expand Down
46 changes: 40 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@
"title": "Deploy",
"category": "Topo"
},
{
"command": "topo.configure.context",
"title": "Topo Configure",
"icon": "$(gear)"
},
{
"command": "topo.deploy.context",
"title": "Topo Deploy",
Expand All @@ -163,6 +168,12 @@
"title": "Topo Stop",
"icon": "$(stop)"
},
{
"command": "topo.configureProject",
"title": "Configure",
"icon": "$(gear)",
"category": "Topo"
},
{
"command": "topo.deployProject",
"title": "Deploy",
Expand Down Expand Up @@ -222,26 +233,36 @@
"menus": {
"editor/title/context": [
{
"command": "topo.deploy.context",
"command": "topo.configure.context",
"group": "topo@1",
"when": "resourceFilename =~ /^compose\\.ya?ml$/"
},
{
"command": "topo.stop.context",
"command": "topo.deploy.context",
"group": "topo@2",
"when": "resourceFilename =~ /^compose\\.ya?ml$/"
},
{
"command": "topo.stop.context",
"group": "topo@3",
"when": "resourceFilename =~ /^compose\\.ya?ml$/"
}
],
"explorer/context": [
{
"command": "topo.deploy.context",
"command": "topo.configure.context",
"group": "topo@1",
"when": "resourceFilename =~ /^compose\\.ya?ml$/"
},
{
"command": "topo.stop.context",
"command": "topo.deploy.context",
"group": "topo@2",
"when": "resourceFilename =~ /^compose\\.ya?ml$/"
},
{
"command": "topo.stop.context",
"group": "topo@3",
"when": "resourceFilename =~ /^compose\\.ya?ml$/"
}
],
"commandPalette": [
Expand Down Expand Up @@ -293,6 +314,10 @@
"command": "topo.openContainerInBrowser",
"when": "false"
},
{
"command": "topo.configure.context",
"when": "false"
},
{
"command": "topo.deploy.context",
"when": "false"
Expand All @@ -301,6 +326,10 @@
"command": "topo.stop.context",
"when": "false"
},
{
"command": "topo.configureProject",
"when": "false"
},
{
"command": "topo.deployProject",
"when": "false"
Expand Down Expand Up @@ -373,14 +402,19 @@
"group": "inline@5"
},
{
"command": "topo.deployProject",
"command": "topo.configureProject",
"when": "view === topo.projects && viewItem =~ /Project/",
"group": "inline@1"
},
{
"command": "topo.stopProject",
"command": "topo.deployProject",
"when": "view === topo.projects && viewItem =~ /Project/",
"group": "inline@2"
},
{
"command": "topo.stopProject",
"when": "view === topo.projects && viewItem =~ /Project/",
"group": "inline@3"
}
],
"view/title": [
Expand Down
100 changes: 100 additions & 0 deletions src/actions/configure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import os from 'node:os';
import path from 'node:path';
import * as vscode from 'vscode';
import { mock, MockProxy } from 'vitest-mock-extended';
import { unloaded } from '../util/loadable';
import { TaskExecutor } from '../util/taskExecutor';
import { ProjectTreeItem } from '../views/treeItems/projectTreeItem';
import { Configure, configure } from './configure';

describe('Configure', () => {
const composeFileUri = vscode.Uri.file(
path.join(os.tmpdir(), 'demo', 'compose.yml'),
);
const projectPath = path.dirname(composeFileUri.fsPath);
let taskExecutor: MockProxy<TaskExecutor>;
let configureAction: Configure;

function projectTreeItem(): ProjectTreeItem {
return new ProjectTreeItem(
{
name: 'demo',
uri: vscode.Uri.file(projectPath),
composeFileUri,
workspaceIndex: 0,
workspaceName: 'workspace',
},
false,
unloaded(),
);
}

function expectConfigureTask(task: vscode.Task): void {
expect(task.name).toBe('Configure demo');
expect(task.execution).toMatchObject({
process: 'topo',
args: ['configure', '--file', 'compose.yml'],
options: { cwd: projectPath },
});
}

beforeEach(() => {
taskExecutor = mock<TaskExecutor>();
configureAction = new Configure(taskExecutor);
});

afterEach(() => {
vi.resetAllMocks();
});

it('configures the selected compose file', async () => {
await configureAction.configureContextCommandHandler(composeFileUri);

expect(taskExecutor.run).toHaveBeenCalledOnce();
expectConfigureTask(taskExecutor.run.mock.calls[0][0]);
});

it('configures the project tree item compose file', async () => {
await configureAction.configureProjectCommandHandler(projectTreeItem());

expect(taskExecutor.run).toHaveBeenCalledOnce();
expectConfigureTask(taskExecutor.run.mock.calls[0][0]);
});

it('throws when the context command has no compose file', async () => {
await expect(
configureAction.configureContextCommandHandler(),
).rejects.toThrow(
'No compose.yaml or compose.yml selected for configuration',
);

expect(taskExecutor.run).not.toHaveBeenCalled();
});

it('throws when the project command has no project tree item', async () => {
await expect(
configureAction.configureProjectCommandHandler(undefined),
).rejects.toThrow('This operation cannot be performed on this item');

expect(taskExecutor.run).not.toHaveBeenCalled();
});

it('reports successful configuration', async () => {
await configure(taskExecutor, composeFileUri.fsPath);

expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
'demo configured successfully.',
);
});

it('reports task failure', async () => {
taskExecutor.run.mockRejectedValueOnce(new Error('configure failed'));

await configure(taskExecutor, composeFileUri.fsPath);

expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
'Configuring demo failed: configure failed',
);
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
});
});
72 changes: 72 additions & 0 deletions src/actions/configure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { getErrorMessage } from '../util/getErrorMessage';
import { createProcessTask } from '../util/task';
import { TaskExecutor } from '../util/taskExecutor';
import { assertProjectTreeItem } from './util/assertProjectTreeItem';

const viewLogsItem: vscode.MessageItem = {
title: 'View Logs',
};

export class Configure {
constructor(private readonly taskExecutor: TaskExecutor) {}

public async configureContextCommandHandler(
resource?: vscode.Uri,
): Promise<void> {
if (!resource) {
throw new Error(
'No compose.yaml or compose.yml selected for configuration',
);
}

await configure(this.taskExecutor, resource.fsPath);
}

public async configureProjectCommandHandler(
treeNode: unknown,
): Promise<void> {
assertProjectTreeItem(treeNode);
await this.configureContextCommandHandler(treeNode.composeFileUri);
}
}

export async function configure(
taskExecutor: TaskExecutor,
composeFilePath: string,
): Promise<void> {
const projectName = path.basename(path.dirname(composeFilePath));
const task = createProcessTask(
`Configure ${projectName}`,
['topo', 'configure', '--file', path.basename(composeFilePath)],
{
cwd: path.dirname(composeFilePath),
},
);
const taskName = task.name;

try {
await taskExecutor.run(task);
} catch (error: unknown) {
const terminal = vscode.window.terminals.find(
(candidate) => candidate.name === taskName,
);
const actions: vscode.MessageItem[] = [];
if (terminal) {
actions.push(viewLogsItem);
}
const choice = await vscode.window.showErrorMessage(
`Configuring ${projectName} failed: ${getErrorMessage(error)}`,
...actions,
);
if (choice?.title === viewLogsItem.title) {
terminal?.show();
}
return;
}

vscode.window.showInformationMessage(
`${projectName} configured successfully.`,
);
}
2 changes: 1 addition & 1 deletion src/actions/containerLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ProjectController } from '../controllers/projectController';
import { isWrappedError } from '../errors/wrappedError';
import { ContainerCommands } from '../services/containerCommands';
import { showAndLogError } from '../util/showAndLog';
import { assertContainerTreeItem } from '../views/treeItems/assertContainerTreeItem';
import { assertContainerTreeItem } from './util/assertContainerTreeItem';

const containerOperationMethods = {
start: 'startContainer',
Expand Down
4 changes: 1 addition & 3 deletions src/actions/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,7 @@ describe('Deploy', () => {
it('throws when project command is called without a project tree item', async () => {
await expect(
deployAction.deployProjectCommandHandler(undefined),
).rejects.toThrow(
'No compose.yaml or compose.yml selected for deployment',
);
).rejects.toThrow('This operation cannot be performed on this item');

expect(taskExecutor.run).not.toHaveBeenCalled();
expect(
Expand Down
9 changes: 2 additions & 7 deletions src/actions/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
getPreferredComposeFiles,
type ComposeFileMetadata,
} from '../util/composeFile';
import { ProjectTreeItem } from '../views/treeItems/projectTreeItem';
import { assertProjectTreeItem } from './util/assertProjectTreeItem';
import {
assertTargetConnected,
assertTargetSelected,
Expand Down Expand Up @@ -93,12 +93,7 @@ export class Deploy {
}

public async deployProjectCommandHandler(treeNode: unknown): Promise<void> {
if (!(treeNode instanceof ProjectTreeItem)) {
throw new Error(
'No compose.yaml or compose.yml selected for deployment',
);
}

assertProjectTreeItem(treeNode);
await this.deployContextCommandHandler(treeNode.composeFileUri);
}

Expand Down
2 changes: 1 addition & 1 deletion src/actions/openContainerInBrowser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import { getContainerWebEndpoints } from '../util/getContainerWebEndpoints';
import type { ContainerWebEndpoint } from '../util/getContainerWebEndpoints';
import { assertContainerTreeItem } from '../views/treeItems/assertContainerTreeItem';
import { assertContainerTreeItem } from './util/assertContainerTreeItem';

type EndpointQuickPickItem = vscode.QuickPickItem & {
endpoint: ContainerWebEndpoint;
Expand Down
2 changes: 1 addition & 1 deletion src/actions/openContainerShell.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import { ContainerItem } from '../util/types';
import { ContainerCommands } from '../services/containerCommands';
import { assertContainerTreeItem } from '../views/treeItems/assertContainerTreeItem';
import { assertContainerTreeItem } from './util/assertContainerTreeItem';

export class OpenContainerShell {
constructor(private readonly containerCommands: ContainerCommands) {}
Expand Down
2 changes: 1 addition & 1 deletion src/actions/stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ describe('Stop', () => {
it('throws when project command is called without a project tree item', async () => {
await expect(
stopAction.stopProjectCommandHandler(undefined),
).rejects.toThrow('No compose.yaml or compose.yml selected for stop');
).rejects.toThrow('This operation cannot be performed on this item');

expect(taskExecutor.run).not.toHaveBeenCalled();
expect(
Expand Down
Loading