diff --git a/README.md b/README.md index cd48eb1..951c050 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ Open VS Code Settings (Ctrl+, / Cmd+,) and search for `adoext` to customize: | `adoext.workItemQueries` | Custom saved work item query filters | (defaults) | | `adoext.pullRequestQueries` | Custom saved PR query filters | (defaults) | | `adoext.projectsByOrganization` | Multi-org project selection | `{}` | +| `adoext.enableWikiView` | Enable the optional Wiki view (read-only) | `false` | --- @@ -286,6 +287,7 @@ Happy coding! 🎉 | `adoext.pullRequestFilter` | `mine` | Legacy pull request filter used as compatibility fallback when older settings are migrated. | | `adoext.useRemoteWorkItemIcons` | `true` | Prefer Azure DevOps work item type icons (including custom process icons). Disable to force bundled fallback icons. | | `adoext.planningAssignedFilter` | `all` | Assignee filter for Backlog/Sprint/Board views (`all` or `mine`). | +| `adoext.enableWikiView` | `false` | Enable the optional Wiki view (read-only) for browsing Azure DevOps wiki pages. | ### Query and bucket management diff --git a/package.json b/package.json index 0e0acc9..3d2a009 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,13 @@ "name": "Pipelines", "icon": "media/icons/ado.svg", "contextualTitle": "Azure DevOps Pipelines" + }, + { + "id": "adoext.wiki", + "name": "Wiki", + "icon": "media/icons/ado.svg", + "contextualTitle": "Azure DevOps Wiki", + "when": "adoext.wikiEnabled" } ] }, @@ -166,6 +173,24 @@ "category": "ADOExt", "icon": "$(open-preview)" }, + { + "command": "adoext.refreshWiki", + "title": "Refresh Wiki", + "category": "ADOExt", + "icon": "$(refresh)" + }, + { + "command": "adoext.searchWikiPages", + "title": "Search Wiki Pages", + "category": "ADOExt", + "icon": "$(search)" + }, + { + "command": "adoext.clearWikiSearch", + "title": "Clear Wiki Search", + "category": "ADOExt", + "icon": "$(clear-all)" + }, { "command": "adoext.setPlanningAssignedFilter", "title": "Filter Planning Items by Assignee", @@ -268,6 +293,18 @@ "category": "ADOExt", "icon": "$(output)" }, + { + "command": "adoext.openWikiPageInBrowser", + "title": "Open Wiki Page in Browser", + "category": "ADOExt", + "icon": "$(link-external)" + }, + { + "command": "adoext.copyWikiPageLink", + "title": "Copy Wiki Page Link", + "category": "ADOExt", + "icon": "$(copy)" + }, { "command": "adoext.rerunPipelineRun", "title": "Re-run Pipeline", @@ -574,17 +611,17 @@ }, { "command": "adoext.selectOrganization", - "when": "view == adoext.workItems || view == adoext.backlog || view == adoext.sprints || view == adoext.boards || view == adoext.pullRequests || view == adoext.pipelines", + "when": "view == adoext.workItems || view == adoext.backlog || view == adoext.sprints || view == adoext.boards || view == adoext.pullRequests || view == adoext.pipelines || view == adoext.wiki", "group": "navigation" }, { "command": "adoext.detectRepoContext", - "when": "view == adoext.workItems || view == adoext.backlog || view == adoext.sprints || view == adoext.boards || view == adoext.pullRequests || view == adoext.pipelines", + "when": "view == adoext.workItems || view == adoext.backlog || view == adoext.sprints || view == adoext.boards || view == adoext.pullRequests || view == adoext.pipelines || view == adoext.wiki", "group": "navigation" }, { "command": "adoext.signIn", - "when": "(view == adoext.workItems || view == adoext.backlog || view == adoext.sprints || view == adoext.boards || view == adoext.pullRequests || view == adoext.pipelines) && !adoext.isSignedIn", + "when": "(view == adoext.workItems || view == adoext.backlog || view == adoext.sprints || view == adoext.boards || view == adoext.pullRequests || view == adoext.pipelines || view == adoext.wiki) && !adoext.isSignedIn", "group": "navigation" }, { @@ -626,6 +663,21 @@ "command": "adoext.setPipelineRunsGroupBy", "when": "view == adoext.pipelines", "group": "navigation" + }, + { + "command": "adoext.refreshWiki", + "when": "view == adoext.wiki", + "group": "navigation" + }, + { + "command": "adoext.searchWikiPages", + "when": "view == adoext.wiki", + "group": "navigation" + }, + { + "command": "adoext.clearWikiSearch", + "when": "view == adoext.wiki && adoext.wikiHasSearch", + "group": "navigation" } ], "view/item/context": [ @@ -753,6 +805,16 @@ "command": "adoext.openPipelineStepLog", "when": "viewItem == pipelineStepLog", "group": "inline" + }, + { + "command": "adoext.openWikiPageInBrowser", + "when": "viewItem == wikiPage", + "group": "navigation" + }, + { + "command": "adoext.copyWikiPageLink", + "when": "viewItem == wikiPage", + "group": "navigation" } ], "comments/commentThread/context": [ @@ -1117,6 +1179,11 @@ "default": 300, "minimum": 60, "description": "How often (in seconds) ADOExt polls tracked pull requests for notifications. Ignored when all notifications are disabled." + }, + "adoext.enableWikiView": { + "type": "boolean", + "default": false, + "description": "Enable the optional Wiki view (read-only) for browsing Azure DevOps wiki pages." } } } diff --git a/src/api/adoClient.ts b/src/api/adoClient.ts index 814e347..f064db9 100644 --- a/src/api/adoClient.ts +++ b/src/api/adoClient.ts @@ -4,8 +4,9 @@ import type { IGitApi } from 'azure-devops-node-api/GitApi'; import type { ICoreApi } from 'azure-devops-node-api/CoreApi'; import type { IPolicyApi } from 'azure-devops-node-api/PolicyApi'; import type { IBuildApi } from 'azure-devops-node-api/BuildApi'; +import type { IWikiApi } from 'azure-devops-node-api/WikiApi'; import { CommentExpandOptions, QueryExpand, TreeStructureGroup, WorkItemExpand } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; -import { GitVersionType, VersionControlChangeType, GitStatusState, PullRequestAsyncStatus, PullRequestMergeFailureType, PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { GitVersionType, VersionControlChangeType, VersionControlRecursionType, GitStatusState, PullRequestAsyncStatus, PullRequestMergeFailureType, PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { BuildReason, BuildResult, BuildStatus } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { Operation } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import { normalizeWorkItemTypeName, workItemTypeScopeKey } from '../utils/workItemTypeIcons'; @@ -35,6 +36,8 @@ import type { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterface import type { IdentityRef, JsonPatchDocument, JsonPatchOperation, ResourceRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import type { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; import { PolicyEvaluationStatus } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; +import type { WikiPageDetail, WikiV2 } from 'azure-devops-node-api/interfaces/WikiInterfaces'; +import type { IncomingMessage } from 'http'; export type { WorkItem, @@ -60,6 +63,13 @@ export { GitStatusState, PolicyEvaluationStatus, PullRequestAsyncStatus, PullReq export type PipelineRunsFilter = 'all' | 'running' | 'failed' | 'mine'; +export interface WikiPageContent { + path: string; + markdown: string; + lastModified?: string; + etag?: string; +} + /** A flattened representation of a saved query (non-folder). */ export interface SavedQuery { id: string; @@ -1472,6 +1482,50 @@ export class AdoClient { } } + async listWikis(project: string, organization?: string): Promise { + const wikiApi: IWikiApi = await this.getConnectionFor(organization).getWikiApi(); + return wikiApi.getAllWikis(project); + } + + async listWikiPages(project: string, wikiIdentifier: string, organization?: string): Promise { + const wikiApi: IWikiApi = await this.getConnectionFor(organization).getWikiApi(); + const pages = await wikiApi.getPagesBatch({ top: 1000 }, project, wikiIdentifier); + return Array.isArray(pages) ? pages : []; + } + + async getWikiPageMarkdown( + project: string, + wikiIdentifier: string, + pagePath: string, + organization?: string + ): Promise { + const wikiApi: IWikiApi = await this.getConnectionFor(organization).getWikiApi(); + const stream = await wikiApi.getPageText( + project, + wikiIdentifier, + pagePath, + VersionControlRecursionType.None + ); + const message = stream as unknown as IncomingMessage; + const lastModified = typeof message.headers['last-modified'] === 'string' + ? message.headers['last-modified'] + : Array.isArray(message.headers['last-modified']) + ? message.headers['last-modified'][0] + : undefined; + const etag = typeof message.headers.etag === 'string' + ? message.headers.etag + : Array.isArray(message.headers.etag) + ? message.headers.etag[0] + : undefined; + + return { + path: pagePath, + markdown: await this.streamToString(message), + ...(lastModified ? { lastModified } : {}), + ...(etag ? { etag } : {}) + }; + } + private streamToString(stream: NodeJS.ReadableStream): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; diff --git a/src/commands/wikiCommands.ts b/src/commands/wikiCommands.ts new file mode 100644 index 0000000..fac8157 --- /dev/null +++ b/src/commands/wikiCommands.ts @@ -0,0 +1,73 @@ +import * as vscode from 'vscode'; +import type { AdoClient } from '../api/adoClient'; +import type { ConfigManager } from '../config/configManager'; +import type { WikiProvider, WikiPageNode } from '../providers/wikiProvider'; +import { showInformationMessage } from '../utils/notifications'; +import { WikiPagePanel } from '../views/wikiPagePanel'; + +export async function refreshWiki(provider: WikiProvider): Promise { + provider.refresh(); +} + +export async function searchWikiPages(provider: WikiProvider): Promise { + const value = await vscode.window.showInputBox({ + prompt: 'Filter wiki pages (matches page path and name)', + placeHolder: 'e.g. onboarding, /Team/Docs' + }); + if (value === undefined) { + return; + } + provider.setSearchQuery(value); +} + +export async function clearWikiSearch(provider: WikiProvider): Promise { + provider.clearSearchQuery(); +} + +export async function viewWikiPage( + context: vscode.ExtensionContext, + client: AdoClient, + config: ConfigManager, + node: WikiPageNode +): Promise { + await WikiPagePanel.show(context, client, config, { + organization: node.scope.organization, + project: node.scope.project, + wikiId: node.wiki.id, + wikiName: node.wiki.name, + wikiRemoteUrl: node.wiki.remoteUrl, + pagePath: node.path + }); +} + +export async function openWikiPageInBrowser(node: WikiPageNode): Promise { + const pageUrl = buildWikiPageUrl(node.wiki.remoteUrl, node.path); + if (!pageUrl) { + return; + } + await vscode.env.openExternal(vscode.Uri.parse(pageUrl)); +} + +export async function copyWikiPageLink(node: WikiPageNode): Promise { + const pageUrl = buildWikiPageUrl(node.wiki.remoteUrl, node.path); + if (!pageUrl) { + return; + } + await vscode.env.clipboard.writeText(pageUrl); + showInformationMessage('Wiki page link copied to clipboard.'); +} + +function buildWikiPageUrl(wikiRemoteUrl: string | undefined, pagePath: string): string | undefined { + if (!wikiRemoteUrl) { + return undefined; + } + try { + const url = new URL(wikiRemoteUrl); + const normalized = pagePath.startsWith('/') ? pagePath : `/${pagePath}`; + url.searchParams.set('pagePath', normalized); + return url.toString(); + } catch { + return undefined; + } +} + diff --git a/src/config/configManager.ts b/src/config/configManager.ts index 301c73c..ad20e32 100644 --- a/src/config/configManager.ts +++ b/src/config/configManager.ts @@ -417,6 +417,11 @@ export class ConfigManager { return organizations.length > 0 && organizations.some(org => this.getProjectSelection(org).length > 0); } + /** Enables the optional Wiki view contribution. */ + get enableWikiView(): boolean { + return this.config.get('enableWikiView', false); + } + private normalizeList(values: readonly string[]): string[] { const seen = new Set(); const normalized: string[] = []; diff --git a/src/extension.ts b/src/extension.ts index 6f9128a..f628b21 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,6 +13,7 @@ import { import { PipelinesProvider, type PipelineRunNode, type PipelineStepLogNode } from './providers/pipelinesProvider'; import { BacklogProvider, SprintProvider, BoardProvider } from './providers/planningProviders'; import { WorkItemIconResolver } from './providers/workItemIconResolver'; +import { WikiProvider, WikiPageNode } from './providers/wikiProvider'; import { PlanningPanel } from './views/planningPanel'; import { PrCommentController, type CommentReply } from './views/prCommentController'; import { PrDiffCache, PrDiffContentProvider, PR_DIFF_SCHEME } from './views/prContentProvider'; @@ -66,6 +67,7 @@ import { rerunPipelineRun, viewPipelineRunDetails } from './commands/pipelineCommands'; +import { clearWikiSearch, copyWikiPageLink, openWikiPageInBrowser, refreshWiki, searchWikiPages, viewWikiPage } from './commands/wikiCommands'; import { McpServerManager } from './mcp/mcpServerManager'; import { TodoCodeActionProvider } from './views/todoCodeActionProvider'; import { PipelineRunDetailsPanel } from './views/pipelineRunDetailsPanel'; @@ -120,6 +122,14 @@ export async function activate(context: vscode.ExtensionContext): Promise ); } + function updateWikiEnabledContext(): void { + void vscode.commands.executeCommand( + 'setContext', + 'adoext.wikiEnabled', + config.enableWikiView + ); + } + function refreshAllViews(): void { workItemProvider.refresh(); pullRequestProvider.refresh(); @@ -128,6 +138,7 @@ export async function activate(context: vscode.ExtensionContext): Promise backlogProvider.refresh(); sprintProvider.refresh(); boardProvider.refresh(); + wikiProvider.refresh(); } function disconnectAfterAuthLost(): void { @@ -220,6 +231,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const backlogProvider = new BacklogProvider(client, config, workItemIconResolver, recoverAuthAfterAdoError); const sprintProvider = new SprintProvider(client, config, workItemIconResolver, recoverAuthAfterAdoError); const boardProvider = new BoardProvider(client, config, workItemIconResolver, recoverAuthAfterAdoError); + const wikiProvider = new WikiProvider(client, config, recoverAuthAfterAdoError); context.subscriptions.push( vscode.window.registerTreeDataProvider('adoext.workItems', workItemProvider), @@ -227,7 +239,8 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.window.registerTreeDataProvider('adoext.pipelines', pipelinesProvider), vscode.window.registerTreeDataProvider('adoext.backlog', backlogProvider), vscode.window.registerTreeDataProvider('adoext.sprints', sprintProvider), - vscode.window.registerTreeDataProvider('adoext.boards', boardProvider) + vscode.window.registerTreeDataProvider('adoext.boards', boardProvider), + vscode.window.registerTreeDataProvider('adoext.wiki', wikiProvider) ); // ------------------------------------------------------------------------- @@ -403,6 +416,8 @@ export async function activate(context: vscode.ExtensionContext): Promise } updateWorkItemDoneHiddenContext(); + updateWikiEnabledContext(); + void vscode.commands.executeCommand('setContext', 'adoext.wikiHasSearch', false); context.subscriptions.push( vscode.commands.registerCommand('adoext.toggleHideDoneWorkItems', async () => { @@ -441,6 +456,27 @@ export async function activate(context: vscode.ExtensionContext): Promise }) ); + context.subscriptions.push( + vscode.commands.registerCommand('adoext.refreshWiki', async () => { + await ensureSignedIn(); + await refreshWiki(wikiProvider); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('adoext.searchWikiPages', async () => { + await ensureSignedIn(); + await searchWikiPages(wikiProvider); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('adoext.clearWikiSearch', async () => { + await ensureSignedIn(); + await clearWikiSearch(wikiProvider); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand('adoext.setPlanningAssignedFilter', async () => { const current = config.planningAssignedFilter; @@ -890,6 +926,36 @@ export async function activate(context: vscode.ExtensionContext): Promise ) ); + context.subscriptions.push( + vscode.commands.registerCommand( + 'adoext.viewWikiPage', + async (node: WikiPageNode) => { + if (!(await ensureSignedIn())) { return; } + await viewWikiPage(context, client, config, node); + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'adoext.openWikiPageInBrowser', + async (node: WikiPageNode) => { + if (!(await ensureSignedIn())) { return; } + await openWikiPageInBrowser(node); + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'adoext.copyWikiPageLink', + async (node: WikiPageNode) => { + if (!(await ensureSignedIn())) { return; } + await copyWikiPageLink(node); + } + ) + ); + context.subscriptions.push( vscode.commands.registerCommand( 'adoext.rerunPipelineRun', @@ -1158,6 +1224,9 @@ export async function activate(context: vscode.ExtensionContext): Promise if (config.organization && auth.isSignedIn) { client.connect(config.organization); } + if (e.affectsConfiguration('adoext.enableWikiView')) { + updateWikiEnabledContext(); + } if (e.affectsConfiguration('adoext.workItemHideStates')) { updateWorkItemDoneHiddenContext(); } diff --git a/src/providers/wikiProvider.ts b/src/providers/wikiProvider.ts new file mode 100644 index 0000000..0739ce0 --- /dev/null +++ b/src/providers/wikiProvider.ts @@ -0,0 +1,423 @@ +import * as vscode from 'vscode'; +import type { AdoClient } from '../api/adoClient'; +import type { ConfigManager } from '../config/configManager'; +import { + resolveProjectScopes, + scopeKey, + scopeLabel, + type ProjectScope +} from './projectScopes'; +import type { AuthRecoveryHandler } from '../utils/authRecovery'; +import { handleProviderError } from './providerErrors'; + +interface WikiSummary { + id: string; + name: string; + remoteUrl?: string; +} + +interface WikiPageSummary { + id?: number; + path: string; +} + +interface WikiTreeEntry { + segment: string; + path: string; + isPage: boolean; + children: Map; +} + +interface WikiTreeCache { + wiki: WikiSummary; + root: WikiTreeEntry; + entryByPath: Map; + fetchedAt: number; +} + +export class WikiScopeNode extends vscode.TreeItem { + constructor(public readonly scope: ProjectScope) { + super(scopeLabel(scope), vscode.TreeItemCollapsibleState.Expanded); + this.iconPath = new vscode.ThemeIcon('project'); + this.contextValue = 'wikiScope'; + } +} + +export class WikiNode extends vscode.TreeItem { + constructor( + public readonly scope: ProjectScope, + public readonly wiki: WikiSummary + ) { + super(wiki.name, vscode.TreeItemCollapsibleState.Collapsed); + this.iconPath = new vscode.ThemeIcon('book'); + this.contextValue = 'wiki'; + this.tooltip = [ + `Wiki: ${wiki.name}`, + `Project: ${scopeLabel(scope)}` + ].join('\n'); + } +} + +export class WikiPageNode extends vscode.TreeItem { + constructor( + public readonly scope: ProjectScope, + public readonly wiki: WikiSummary, + public readonly path: string, + public readonly isPage: boolean, + hasChildren: boolean + ) { + super( + path === '/' ? 'Home' : path.split('/').filter(Boolean).slice(-1)[0] ?? path, + hasChildren ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None + ); + + this.description = path === '/' ? '/' : undefined; + this.iconPath = path === '/' + ? new vscode.ThemeIcon('home') + : isPage + ? new vscode.ThemeIcon('file-text') + : new vscode.ThemeIcon('folder'); + + this.contextValue = isPage ? 'wikiPage' : 'wikiFolder'; + this.tooltip = [ + `${isPage ? 'Page' : 'Folder'}: ${path}`, + `Wiki: ${wiki.name}`, + `Project: ${scopeLabel(scope)}` + ].join('\n'); + + if (isPage) { + this.command = { + command: 'adoext.viewWikiPage', + title: 'View Wiki Page', + arguments: [this] + }; + } + } +} + +type WikiTreeNode = + | WikiScopeNode + | WikiNode + | WikiPageNode + | vscode.TreeItem; + +export class WikiProvider implements vscode.TreeDataProvider { + private readonly _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private _loading = false; + private _searchQuery = ''; + private readonly _wikiCache = new Map(); + private readonly _wikiTreeCache = new Map(); + + constructor( + private readonly client: AdoClient, + private readonly config: ConfigManager, + private readonly onAuthError?: AuthRecoveryHandler + ) {} + + refresh(): void { + this._wikiCache.clear(); + this._wikiTreeCache.clear(); + this._onDidChangeTreeData.fire(); + } + + setSearchQuery(query: string): void { + this._searchQuery = query.trim(); + void vscode.commands.executeCommand('setContext', 'adoext.wikiHasSearch', Boolean(this._searchQuery)); + this._onDidChangeTreeData.fire(); + } + + clearSearchQuery(): void { + this.setSearchQuery(''); + } + + getTreeItem(element: WikiTreeNode): vscode.TreeItem { + return element; + } + + async getChildren(element?: WikiTreeNode): Promise { + if (element instanceof WikiScopeNode) { + return this.getScopeChildren(element.scope); + } + + if (element instanceof WikiNode) { + return this.getWikiChildren(element.scope, element.wiki); + } + + if (element instanceof WikiPageNode) { + return this.getWikiPageChildren(element); + } + + if (this._loading) { + return []; + } + this._loading = true; + + try { + const setupNode = this.getSetupNode(); + if (setupNode) { + return [setupNode]; + } + + const scopes = await resolveProjectScopes(this.client, this.config); + if (scopes.length === 0) { + return [this.createConfigureNode()]; + } + + const nodes: WikiTreeNode[] = scopes + .map(scope => new WikiScopeNode(scope)) + .sort((left, right) => `${left.label}`.localeCompare(`${right.label}`)); + + if (this._searchQuery) { + const filterNode = new vscode.TreeItem(`Filter: ${this._searchQuery}`, vscode.TreeItemCollapsibleState.None); + filterNode.iconPath = new vscode.ThemeIcon('filter'); + filterNode.command = { command: 'adoext.clearWikiSearch', title: 'Clear Wiki Search' }; + nodes.unshift(filterNode); + } + + return nodes; + } catch (err) { + return handleProviderError(err, 'wiki', this.onAuthError); + } finally { + this._loading = false; + } + } + + private async getScopeChildren(scope: ProjectScope): Promise { + const key = scopeKey(scope); + const cached = this._wikiCache.get(key); + if (cached && Date.now() - cached.fetchedAt < 5 * 60 * 1000) { + return cached.wikis.length > 0 + ? cached.wikis.map(wiki => new WikiNode(scope, wiki)) + : [this.infoNode('No wikis found')]; + } + + try { + const wikis = await this.client.listWikis(scope.project, scope.organization); + const normalized = (wikis ?? []) + .filter(wiki => wiki.id && wiki.name && !wiki.isDisabled) + .map(wiki => ({ + id: wiki.id!, + name: wiki.name!, + ...(wiki.remoteUrl ? { remoteUrl: wiki.remoteUrl } : {}) + })) + .sort((left, right) => left.name.localeCompare(right.name)); + + this._wikiCache.set(key, { wikis: normalized, fetchedAt: Date.now() }); + + if (normalized.length === 0) { + return [this.infoNode('No wikis found')]; + } + return normalized.map(wiki => new WikiNode(scope, wiki)); + } catch (err) { + return handleProviderError(err, `wiki.scope.${key}`, this.onAuthError); + } + } + + private async getWikiChildren(scope: ProjectScope, wiki: WikiSummary): Promise { + let cache: WikiTreeCache | undefined; + try { + cache = await this.getWikiTree(scope, wiki); + } catch (err) { + return handleProviderError(err, `wiki.pages.${this.wikiTreeCacheKey(scope, wiki.id)}`, this.onAuthError); + } + + if (!cache) { + return [this.infoNode('No pages found')]; + } + const query = this._searchQuery.toLowerCase(); + const children: WikiTreeNode[] = []; + + const root = cache.root; + if (root.isPage && this.matchesFilter('/', query)) { + children.push(new WikiPageNode(scope, wiki, '/', true, root.children.size > 0)); + } else if (root.isPage && query) { + // Root page exists but doesn't match query; only include if any child matches. + const hasMatchingChild = [...root.children.values()].some(entry => this.entryMatches(entry, query)); + if (hasMatchingChild) { + children.push(new WikiPageNode(scope, wiki, '/', true, root.children.size > 0)); + } + } else if (root.isPage) { + children.push(new WikiPageNode(scope, wiki, '/', true, root.children.size > 0)); + } + + for (const entry of [...root.children.values()].sort((a, b) => a.segment.localeCompare(b.segment))) { + if (query && !this.entryMatches(entry, query)) { + continue; + } + children.push(new WikiPageNode(scope, wiki, entry.path, entry.isPage, entry.children.size > 0)); + } + + return children.length > 0 ? children : [this.infoNode('No pages found')]; + } + + private async getWikiPageChildren(node: WikiPageNode): Promise { + const cacheKey = this.wikiTreeCacheKey(node.scope, node.wiki.id); + const cache = this._wikiTreeCache.get(cacheKey); + if (!cache) { + return []; + } + + const entry = cache.entryByPath.get(node.path); + if (!entry || entry.children.size === 0) { + return []; + } + + const query = this._searchQuery.toLowerCase(); + return [...entry.children.values()] + .sort((a, b) => a.segment.localeCompare(b.segment)) + .filter(child => !query || this.entryMatches(child, query)) + .map(child => new WikiPageNode(node.scope, node.wiki, child.path, child.isPage, child.children.size > 0)); + } + + private async getWikiTree(scope: ProjectScope, wiki: WikiSummary): Promise { + const key = this.wikiTreeCacheKey(scope, wiki.id); + const cached = this._wikiTreeCache.get(key); + if (cached && Date.now() - cached.fetchedAt < 5 * 60 * 1000) { + return cached; + } + + const pages = await this.loadWikiPages(scope, wiki.id); + if (pages.length === 0) { + return undefined; + } + + const tree = this.buildWikiTree(pages); + const cache: WikiTreeCache = { + wiki, + root: tree.root, + entryByPath: tree.entryByPath, + fetchedAt: Date.now() + }; + this._wikiTreeCache.set(key, cache); + return cache; + } + + private async loadWikiPages(scope: ProjectScope, wikiIdentifier: string): Promise { + const pages = await this.client.listWikiPages(scope.project, wikiIdentifier, scope.organization); + return dedupePages( + (pages ?? []) + .filter(page => typeof page.path === 'string' && page.path.trim()) + .map(page => ({ + ...(typeof page.id === 'number' ? { id: page.id } : {}), + path: normalizeWikiPath(page.path!) + })) + ); + } + + private buildWikiTree(pages: WikiPageSummary[]): { root: WikiTreeEntry; entryByPath: Map } { + const root: WikiTreeEntry = { segment: '', path: '/', isPage: false, children: new Map() }; + const entryByPath = new Map([['/', root]]); + + for (const page of pages) { + const normalized = normalizeWikiPath(page.path); + const segments = normalized.split('/').filter(Boolean); + if (segments.length === 0) { + root.isPage = true; + continue; + } + + let current = root; + let currentPath = ''; + for (const segment of segments) { + currentPath += `/${segment}`; + let entry = current.children.get(segment); + if (!entry) { + entry = { segment, path: currentPath, isPage: false, children: new Map() }; + current.children.set(segment, entry); + entryByPath.set(currentPath, entry); + } + current = entry; + } + current.isPage = true; + } + + return { root, entryByPath }; + } + + private entryMatches(entry: WikiTreeEntry, query: string): boolean { + if (!query) { + return true; + } + if (this.matchesFilter(entry.segment, query) || this.matchesFilter(entry.path, query)) { + return true; + } + for (const child of entry.children.values()) { + if (this.entryMatches(child, query)) { + return true; + } + } + return false; + } + + private matchesFilter(text: string, queryLower: string): boolean { + if (!queryLower) { + return true; + } + return text.toLowerCase().includes(queryLower); + } + + private getSetupNode(): vscode.TreeItem | undefined { + if (!this.config.enableWikiView) { + const node = new vscode.TreeItem('Enable the Wiki view in settings to browse wiki pages.', vscode.TreeItemCollapsibleState.None); + node.iconPath = new vscode.ThemeIcon('info'); + return node; + } + + if (!this.client.isConnected) { + const node = new vscode.TreeItem('Sign in to Azure DevOps...', vscode.TreeItemCollapsibleState.None); + node.command = { command: 'adoext.signIn', title: 'Sign In' }; + node.iconPath = new vscode.ThemeIcon('sign-in'); + return node; + } + + if (!this.config.isConfigured) { + return this.createConfigureNode(); + } + + return undefined; + } + + private createConfigureNode(): vscode.TreeItem { + const node = new vscode.TreeItem('Configure organizations and projects...', vscode.TreeItemCollapsibleState.None); + node.command = { command: 'adoext.selectOrganization', title: 'Select Organizations' }; + node.iconPath = new vscode.ThemeIcon('settings-gear'); + return node; + } + + private infoNode(label: string): vscode.TreeItem { + const node = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); + node.iconPath = new vscode.ThemeIcon('info'); + return node; + } + + private wikiTreeCacheKey(scope: ProjectScope, wikiId: string): string { + return `${scopeKey(scope)}\u0000${wikiId}`; + } +} + +function normalizeWikiPath(path: string): string { + const trimmed = path.trim(); + if (!trimmed) { + return '/'; + } + if (trimmed === '/') { + return '/'; + } + return trimmed.startsWith('/') ? trimmed.replace(/\/+$/g, '') : `/${trimmed.replace(/\/+$/g, '')}`; +} + +function dedupePages(pages: WikiPageSummary[]): WikiPageSummary[] { + const seen = new Set(); + const deduped: WikiPageSummary[] = []; + for (const page of pages) { + const path = normalizeWikiPath(page.path); + if (seen.has(path)) { + continue; + } + seen.add(path); + deduped.push({ ...page, path }); + } + return deduped; +} diff --git a/src/views/wikiPagePanel.ts b/src/views/wikiPagePanel.ts new file mode 100644 index 0000000..2e04f96 --- /dev/null +++ b/src/views/wikiPagePanel.ts @@ -0,0 +1,374 @@ +import * as vscode from 'vscode'; +import type { AdoClient, WikiPageContent } from '../api/adoClient'; +import type { ConfigManager } from '../config/configManager'; +import { buildMessageDocument } from './webviewHtml'; +import { showErrorMessage } from '../utils/notifications'; + +export interface WikiPagePanelScope { + organization: string; + project: string; + wikiId: string; + wikiName: string; + wikiRemoteUrl?: string; + pagePath: string; +} + +export class WikiPagePanel { + private static readonly _panels = new Map(); + + private readonly _panel: vscode.WebviewPanel; + private readonly _disposables: vscode.Disposable[] = []; + + static async show( + context: vscode.ExtensionContext, + client: AdoClient, + config: ConfigManager, + scope: WikiPagePanelScope + ): Promise { + const key = WikiPagePanel.panelKey(scope); + const existing = WikiPagePanel._panels.get(key); + if (existing) { + existing._panel.reveal(vscode.ViewColumn.One); + await existing.refresh(client, config, scope); + return; + } + + const panel = new WikiPagePanel(context, scope); + WikiPagePanel._panels.set(key, panel); + await panel.refresh(client, config, scope); + } + + private static panelKey(scope: WikiPagePanelScope): string { + return `${scope.organization}\u0000${scope.project}\u0000${scope.wikiId}\u0000${scope.pagePath}`; + } + + private constructor( + private readonly _context: vscode.ExtensionContext, + private _scope: WikiPagePanelScope + ) { + this._panel = vscode.window.createWebviewPanel( + 'adoext.wikiPage', + `Wiki: ${_scope.wikiName}${_scope.pagePath ? ` · ${_scope.pagePath}` : ''}`, + vscode.ViewColumn.One, + { + enableScripts: false, + retainContextWhenHidden: true + } + ); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + } + + private async refresh(client: AdoClient, config: ConfigManager, scope: WikiPagePanelScope): Promise { + this._scope = scope; + this._panel.webview.html = buildMessageDocument(this._panel.webview, 'Loading wiki page...'); + + if (!client.isConnected) { + this._panel.webview.html = buildMessageDocument(this._panel.webview, 'Sign in to Azure DevOps to load wiki pages.'); + return; + } + + if (!config.isConfigured) { + this._panel.webview.html = buildMessageDocument(this._panel.webview, 'Select organizations and projects to load wiki pages.'); + return; + } + + try { + const page = await client.getWikiPageMarkdown(scope.project, scope.wikiId, scope.pagePath, scope.organization); + const pageUrl = buildWikiPageUrl(scope.wikiRemoteUrl, scope.pagePath); + const html = renderWikiPageHtml(this._panel.webview, scope, page, pageUrl); + this._panel.title = `Wiki: ${scope.wikiName}${scope.pagePath ? ` · ${scope.pagePath}` : ''}`; + this._panel.webview.html = html; + } catch (err) { + this._panel.webview.html = buildMessageDocument(this._panel.webview, 'Failed to load wiki page.'); + showErrorMessage(`Failed to load wiki page: ${formatError(err)}`); + } + } + + dispose(): void { + WikiPagePanel._panels.delete(WikiPagePanel.panelKey(this._scope)); + while (this._disposables.length) { + this._disposables.pop()?.dispose(); + } + } +} + +function renderWikiPageHtml( + webview: vscode.Webview, + scope: WikiPagePanelScope, + page: WikiPageContent, + pageUrl: string | undefined +): string { + const csp = [ + "default-src 'none'", + `img-src ${webview.cspSource} https: data: https://*.dev.azure.com https://*.visualstudio.com`, + `style-src ${webview.cspSource} 'unsafe-inline'` + ].join('; '); + + const markdownHtml = renderWikiMarkdown(page.markdown, scope.wikiRemoteUrl); + const lastUpdated = page.lastModified ? `Last updated: ${escapeHtml(page.lastModified)}` : ''; + const openLink = pageUrl + ? `Open in browser` + : ''; + + return /* html */` + + + + + +${escapeHtml(`Wiki: ${scope.wikiName}`)} + + + +
+${escapeHtml(`${scope.organization}/${scope.project} · ${scope.wikiName} · ${scope.pagePath}`)} +${lastUpdated ? `${lastUpdated}` : ''} +${openLink} +
+
${markdownHtml}
+ +`; +} + +function renderWikiMarkdown(markdown: string, wikiRemoteUrl?: string): string { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const html: string[] = []; + const paragraph: string[] = []; + let inCodeBlock = false; + let listMode: 'ul' | 'ol' | undefined; + + const baseOrigin = wikiRemoteUrl ? safeOrigin(wikiRemoteUrl) : undefined; + + const flushParagraph = () => { + if (paragraph.length === 0) { + return; + } + const text = paragraph.join(' ').trim(); + if (text) { + html.push(`

${renderInline(text, baseOrigin)}

`); + } + paragraph.length = 0; + }; + + const closeList = () => { + if (!listMode) { + return; + } + html.push(listMode === 'ul' ? '' : ''); + listMode = undefined; + }; + + for (const rawLine of lines) { + const line = rawLine.replace(/\t/g, ' '); + + const fenceMatch = line.match(/^```/); + if (fenceMatch) { + flushParagraph(); + closeList(); + inCodeBlock = !inCodeBlock; + html.push(inCodeBlock ? '
' : '
'); + continue; + } + + if (inCodeBlock) { + html.push(`${escapeHtml(line)}\n`); + continue; + } + + if (!line.trim()) { + flushParagraph(); + closeList(); + continue; + } + + const headingMatch = line.match(/^(#{1,6})\\s+(.+)$/); + if (headingMatch) { + flushParagraph(); + closeList(); + const level = headingMatch[1].length; + html.push(`${renderInline(headingMatch[2].trim(), baseOrigin)}`); + continue; + } + + const quoteMatch = line.match(/^>\\s?(.*)$/); + if (quoteMatch) { + flushParagraph(); + closeList(); + html.push(`

${renderInline(quoteMatch[1].trim(), baseOrigin)}

`); + continue; + } + + const ulMatch = line.match(/^[-*+]\\s+(.+)$/); + if (ulMatch) { + flushParagraph(); + if (listMode && listMode !== 'ul') { + closeList(); + } + if (!listMode) { + listMode = 'ul'; + html.push('
    '); + } + html.push(`
  • ${renderInline(ulMatch[1].trim(), baseOrigin)}
  • `); + continue; + } + + const olMatch = line.match(/^\\d+\\.\\s+(.+)$/); + if (olMatch) { + flushParagraph(); + if (listMode && listMode !== 'ol') { + closeList(); + } + if (!listMode) { + listMode = 'ol'; + html.push('
      '); + } + html.push(`
    1. ${renderInline(olMatch[1].trim(), baseOrigin)}
    2. `); + continue; + } + + paragraph.push(line.trim()); + } + + flushParagraph(); + closeList(); + + return html.join(''); +} + +function renderInline(text: string, baseOrigin?: string): string { + const tokenRe = /(`[^`]+`|!\\[[^\\]]*\\]\\([^\\)]+\\)|\\[[^\\]]+\\]\\([^\\)]+\\))/g; + let result = ''; + let lastIndex = 0; + for (;;) { + const match = tokenRe.exec(text); + if (!match) { + break; + } + result += escapeHtml(text.slice(lastIndex, match.index)); + const token = match[0]; + if (token.startsWith('`')) { + const content = token.slice(1, -1); + result += `${escapeHtml(content)}`; + } else if (token.startsWith('![')) { + const parsed = parseMarkdownLink(token.slice(1)); + if (parsed && parsed.url) { + const url = resolveUrl(parsed.url, baseOrigin); + if (url && isSafeUrl(url, true)) { + result += `${escapeAttribute(parsed.text)}`; + } else { + result += escapeHtml(token); + } + } else { + result += escapeHtml(token); + } + } else if (token.startsWith('[')) { + const parsed = parseMarkdownLink(token); + if (parsed && parsed.url) { + const url = resolveUrl(parsed.url, baseOrigin); + if (url && isSafeUrl(url, false)) { + result += `${escapeHtml(parsed.text)}`; + } else { + result += escapeHtml(parsed.text); + } + } else { + result += escapeHtml(token); + } + } else { + result += escapeHtml(token); + } + lastIndex = match.index + token.length; + } + result += escapeHtml(text.slice(lastIndex)); + return result; +} + +function parseMarkdownLink(token: string): { text: string; url: string } | undefined { + const match = token.match(/^\[([^\]]*)\]\(([^)]+)\)$/); + if (!match) { + return undefined; + } + return { text: match[1] ?? '', url: match[2] ?? '' }; +} + +function resolveUrl(url: string, baseOrigin?: string): string | undefined { + const trimmed = url.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.startsWith('/') && baseOrigin) { + try { + return new URL(trimmed, baseOrigin).toString(); + } catch { + return undefined; + } + } + return trimmed; +} + +function safeOrigin(rawUrl: string): string | undefined { + try { + const url = new URL(rawUrl); + return url.origin; + } catch { + return undefined; + } +} + +function isSafeUrl(url: string, isImage: boolean): boolean { + const lower = url.trim().toLowerCase(); + if (lower.startsWith('https://') || lower.startsWith('http://') || lower.startsWith('#')) { + return true; + } + if (lower.startsWith('data:image/')) { + const mimeEnd = lower.search(/[;,]/); + const mime = mimeEnd > 0 ? lower.slice(0, mimeEnd) : lower; + return mime !== 'data:image/svg+xml'; + } + return isImage ? lower.startsWith('/') : false; +} + +function buildWikiPageUrl(wikiRemoteUrl: string | undefined, pagePath: string): string | undefined { + if (!wikiRemoteUrl) { + return undefined; + } + try { + const url = new URL(wikiRemoteUrl); + const normalized = pagePath.startsWith('/') ? pagePath : `/${pagePath}`; + url.searchParams.set('pagePath', normalized); + return url.toString(); + } catch { + return undefined; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function escapeAttribute(text: string): string { + return escapeHtml(text).replace(/'/g, '''); +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +}