Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
468 changes: 468 additions & 0 deletions docs/design/2026-07-19-webshell-git-log.md

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions docs/users/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Commands for adjusting interface appearance and work environment.
| → `detail` | Show per-item context usage breakdown | `/context detail` |
| `/history` | Control history display preferences and visibility | `/history collapse-on-resume`, `/history expand-on-resume`, `/history expand-now` |
| `/diff` | Open an interactive diff viewer showing uncommitted changes and per-turn diffs. Use ←/→ to switch between current git diff and individual conversation turns, ↑/↓ to browse files | `/diff` |
| `/log` | Open a commit history viewer for the workspace (Web Shell only) | `/log` |
| `/theme` | Change Qwen Code visual theme | `/theme` |
| `/vim` | Turn input area Vim editing mode on/off | `/vim` |
| `/voice` | Toggle voice dictation input | `/voice`, `/voice hold`, `/voice tap`, `/voice off`, `/voice status` |
Expand Down Expand Up @@ -310,6 +311,59 @@ In headless (`--prompt`) or non-interactive contexts, `/diff` prints a plain-tex
+3 -2 README.md
```

**Web Shell:** In the Web Shell UI (`qwen serve`), `/diff` opens a graphical diff dialog. A tab bar at the top lets you switch between the **Changes** view and the **History** view (`/log`).

#### History Viewer (`/log`) — Web Shell only

The `/log` command opens a commit history browser for the current workspace. It is available only in the Web Shell UI; the CLI/TUI does not have this command.

**How it works:**

`/log` opens a dialog listing commits in reverse chronological order (newest first). Each row shows:

- Short SHA (monospace, with a copy button for the full SHA)
- Commit subject (single line)
- Author name and relative time (e.g. "2h ago")
- Branch/tag ref labels, when present
- A merge icon (⎇) for merge commits

Click a commit row to expand its details on demand:

- Full commit message body
- File change statistics (files changed, lines added/removed, per-file breakdown)

Use **Load more** at the bottom to fetch the next page of commits (50 per page).

**Example:**

```
┌─ History ──────────────────────────── 50 commits ─ ✕ ┐
│ │
│ a1b2c3d feat(cli): add --json flag 2h ago │
│ wenshao │
│ │
│ e4f5g6h fix(core): handle null config 5h ago │
│ dev · main v1.2.0 │
│ │
│ ▼ 789abcd refactor: simplify parser 1d ago │
│ ┌─────────────────────────────────────────────┐ │
│ │ Broke the monolithic parse() into smaller │ │
│ │ functions for readability. │ │
│ │ │ │
│ │ 3 files · +45 −12 │ │
│ │ +30 −8 src/parser.ts │ │
│ │ +10 −2 src/utils.ts │ │
│ │ +5 −2 test/parser.test.ts │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ [ Load more ] │
└───────────────────────────────────────────────────────┘
```

> [!note]
>
> `/log` requires a git repository workspace. If the workspace is not a git repository or has no commits, the dialog shows a placeholder message.

### 1.9 Information, Settings, and Help

Commands for obtaining information and performing system settings.
Expand Down
2 changes: 1 addition & 1 deletion docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ qwen serve

The default bind is `127.0.0.1:4170`. Bearer auth is **off** on loopback so local development "just works". The daemon registers the current working directory as its primary workspace; use an absolute `--workspace /path/to/dir` to override it, and repeat the flag to register additional isolated runtimes.

**Open the Web Shell UI.** Browse to `http://127.0.0.1:4170/` (or start the daemon with `qwen serve --open` to launch it automatically) for the full browser terminal — chat, diffs, tool calls, and permission prompts. The UI is served at the daemon root on the same origin as the API. The rest of this guide uses raw HTTP so you can script against the API directly.
**Open the Web Shell UI.** Browse to `http://127.0.0.1:4170/` (or start the daemon with `qwen serve --open` to launch it automatically) for the full browser terminal — chat, diffs, commit history, tool calls, and permission prompts. The UI is served at the daemon root on the same origin as the API. The rest of this guide uses raw HTTP so you can script against the API directly.

### 2. Sanity-check it

Expand Down
281 changes: 281 additions & 0 deletions packages/cli/src/serve/routes/workspace-git-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import express from 'express';
import request from 'supertest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchGitLog, fetchGitCommitDetail } from '@qwen-code/qwen-code-core';
import type { AcpSessionBridge } from '../acp-session-bridge.js';
import { sendBridgeError } from '../server/error-response.js';
import {
createWorkspaceRegistry,
type WorkspaceRegistry,
type WorkspaceRuntime,
} from '../workspace-registry.js';
import {
registerWorkspaceGitLogRoutes,
registerWorkspaceQualifiedGitLogRoutes,
} from './workspace-git-log.js';

vi.mock('@qwen-code/qwen-code-core', () => ({
fetchGitLog: vi.fn(),
fetchGitCommitDetail: vi.fn(),
MAX_LOG_LIMIT: 200,
DEFAULT_LOG_LIMIT: 50,
}));

const fetchGitLogMock = vi.mocked(fetchGitLog);
const fetchGitCommitDetailMock = vi.mocked(fetchGitCommitDetail);

function runtime(
workspaceId: string,
workspaceCwd: string,
trusted: boolean,
): WorkspaceRuntime {
return {
workspaceId,
workspaceCwd,
primary: workspaceId === 'primary',
trusted,
bridge: { publishWorkspaceEvent: vi.fn() } as unknown as AcpSessionBridge,
} as WorkspaceRuntime;
}

function registry(runtimes: WorkspaceRuntime[]): WorkspaceRegistry {
return createWorkspaceRegistry(runtimes);
}

const ENTRY = {
sha: 'abcdef1234567890abcdef1234567890abcdef12',
shortSha: 'abcdef1',
authorName: 'Test',
authorEmail: 't@example.com',
authorDate: 1_700_000_000,
subject: 'do a thing',
refs: 'HEAD -> main',
parents: ['0000000000000000000000000000000000000000'],
};

describe('workspace Git log routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns the commit list for the bound workspace', async () => {
fetchGitLogMock.mockResolvedValue({ entries: [ENTRY], hasMore: true });
Comment thread
wenshao marked this conversation as resolved.
const app = express();
registerWorkspaceGitLogRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get('/workspace/git/log');

expect(response.status).toBe(200);
expect(response.body).toMatchObject({
v: 1,
workspaceCwd: '/work/main',
available: true,
hasMore: true,
entries: [
{
sha: ENTRY.sha,
shortSha: 'abcdef1',
subject: 'do a thing',
refs: 'HEAD -> main',
parents: ENTRY.parents,
},
],
});
});

it('reports available=false when the workspace is not a git repo', async () => {
fetchGitLogMock.mockResolvedValue(null);
const app = express();
registerWorkspaceGitLogRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get('/workspace/git/log');

expect(response.status).toBe(200);
expect(response.body).toMatchObject({
available: false,
entries: [],
hasMore: false,
});
});

it('clamps limit to MAX_LOG_LIMIT and passes skip through', async () => {
fetchGitLogMock.mockResolvedValue({ entries: [], hasMore: false });
const app = express();
registerWorkspaceGitLogRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

await request(app).get('/workspace/git/log?limit=9999&skip=30');

expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', {
limit: 200,
skip: 30,
});
});

it('falls back to the default limit for a non-numeric limit', async () => {
fetchGitLogMock.mockResolvedValue({ entries: [], hasMore: false });
const app = express();
registerWorkspaceGitLogRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

await request(app).get('/workspace/git/log?limit=abc');

expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', {
limit: 50,
skip: 0,
});
});

it('clamps a zero limit to one', async () => {
fetchGitLogMock.mockResolvedValue({ entries: [], hasMore: false });
const app = express();
registerWorkspaceGitLogRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

await request(app).get('/workspace/git/log?limit=0');

expect(fetchGitLogMock).toHaveBeenCalledWith('/work/main', {
limit: 1,
skip: 0,
});
});

it('returns commit detail for a valid sha', async () => {
fetchGitCommitDetailMock.mockResolvedValue({
...ENTRY,
body: 'the body',
files: [{ path: 'a.ts', added: 3, removed: 1, isBinary: false }],
filesCount: 1,
linesAdded: 3,
linesRemoved: 1,
hiddenCount: 0,
});
const app = express();
registerWorkspaceGitLogRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get(
`/workspace/git/log/commit?sha=${ENTRY.shortSha}`,
);

expect(response.status).toBe(200);
expect(response.body).toMatchObject({
available: true,
sha: ENTRY.sha,
body: 'the body',
files: [{ path: 'a.ts', added: 3, removed: 1, isBinary: false }],
filesCount: 1,
});
expect(fetchGitCommitDetailMock).toHaveBeenCalledWith(
'/work/main',
ENTRY.shortSha,
);
});

it('rejects a missing sha with 400', async () => {
const app = express();
registerWorkspaceGitLogRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get('/workspace/git/log/commit');

expect(response.status).toBe(400);
expect(response.body).toMatchObject({ errorKind: 'parse_error' });
expect(fetchGitCommitDetailMock).not.toHaveBeenCalled();
});

it('rejects a malformed (non-hex) sha with 400, distinct from a valid miss', async () => {
const app = express();
registerWorkspaceGitLogRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get(
'/workspace/git/log/commit?sha=not-hex',
);

// A 400 (not a 200 available:false) is what lets the client distinguish a
// bad request from a valid lookup that found no commit.
expect(response.status).toBe(400);
expect(response.body).toMatchObject({ errorKind: 'parse_error' });
expect(fetchGitCommitDetailMock).not.toHaveBeenCalled();
});

it('reports available=false for a valid sha with no matching commit', async () => {
fetchGitCommitDetailMock.mockResolvedValue(null);
const app = express();
registerWorkspaceGitLogRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get(
'/workspace/git/log/commit?sha=deadbee',
);

expect(response.status).toBe(200);
expect(response.body).toMatchObject({ available: false });
});

it('rejects an untrusted workspace on the qualified routes', async () => {
const app = express();
const primary = runtime('primary', '/work/main', true);
const untrusted = runtime('untrusted', '/work/untrusted', false);
registerWorkspaceQualifiedGitLogRoutes(app, {
workspaceRegistry: registry([primary, untrusted]),
sendBridgeError,
});

const list = await request(app).get('/workspaces/untrusted/git/log');
const detail = await request(app).get(
'/workspaces/untrusted/git/log/commit?sha=abcdef1',
);

expect(list.status).toBe(403);
expect(detail.status).toBe(403);
expect(fetchGitLogMock).not.toHaveBeenCalled();
expect(fetchGitCommitDetailMock).not.toHaveBeenCalled();
});

it('uses the selected trusted workspace runtime for the qualified log route', async () => {
fetchGitLogMock.mockResolvedValue({ entries: [], hasMore: false });
Comment thread
wenshao marked this conversation as resolved.
const app = express();
const primary = runtime('primary', '/work/main', true);
const secondary = runtime('secondary', '/work/secondary', true);
registerWorkspaceQualifiedGitLogRoutes(app, {
workspaceRegistry: registry([primary, secondary]),
sendBridgeError,
});

const response = await request(app).get('/workspaces/secondary/git/log');

expect(response.status).toBe(200);
expect(fetchGitLogMock).toHaveBeenCalledWith('/work/secondary', {
limit: 50,
skip: 0,
});
});
});
Loading
Loading