diff --git a/package.json b/package.json index 847a3b3..4603e6e 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ }, "devDependencies": { "@biomejs/biome": "2.4.6", + "@preact/preset-vite": "^2.10.5", "@testing-library/preact": "^3.2.4", "@types/chrome": "^0.1.37", "@types/vscode": "^1.118.0", "@vscode/codicons": "^0.0.45", "happy-dom": "^20.9.0", + "preact": "^10.29.1", "typescript": "^5.9.3", "vite": "^6.3.5", "vitest": "^4.1.5" diff --git a/packages/vscode-ext/src/webview/csp.js b/packages/vscode-ext/src/webview/csp.js index 3fb8b99..00a0fd6 100644 --- a/packages/vscode-ext/src/webview/csp.js +++ b/packages/vscode-ext/src/webview/csp.js @@ -21,9 +21,11 @@ * Builds the Content-Security-Policy string for an OctoClock webview. * * Directives: - * default-src 'none' — deny everything not listed below - * script-src 'nonce-' — only scripts carrying the matching nonce run - * style-src cspSource nonce — stylesheets from localResourceRoots + + Active Timer + + +
+ + +`; +} diff --git a/packages/vscode-ext/src/webview/sidebar/active-timer/provider.js b/packages/vscode-ext/src/webview/sidebar/active-timer/provider.js index 2f3f257..ab6239f 100644 --- a/packages/vscode-ext/src/webview/sidebar/active-timer/provider.js +++ b/packages/vscode-ext/src/webview/sidebar/active-timer/provider.js @@ -18,281 +18,87 @@ import * as vscode from 'vscode'; import { StorageService } from '../../../../../core/src/services/storage.service.js'; import { STORAGE_KEYS } from '../../../../../core/src/utils/constants.utils.js'; -import { buildCsp } from '../../csp.js'; -import { getNonce } from '../../nonce.js'; +import { getHtml } from './html.js'; export class ActiveTimerProvider { - static viewType = 'octoclock.activeTimer'; - - /** @type {vscode.WebviewView | undefined} */ - _view = undefined; - - /** @type {string | null} */ - _activeIssue = null; - - /** @type {string | null} */ - _startTime = null; - - /** - * @param {vscode.ExtensionContext} context - * @param {import('../../../../../core/src/ports/storage-events.port.js').StorageEventsPort} events - */ - constructor(context, events) { - this._context = context; - - const unsubscribe = events.subscribe((event) => { - if (event.type === 'set') { - if (event.key === STORAGE_KEYS.ACTIVE_ISSUE) this._activeIssue = event.value ?? null; - if (event.key === STORAGE_KEYS.START_TIME) this._startTime = event.value ?? null; - } else if (event.type === 'remove') { - if (event.key === STORAGE_KEYS.ACTIVE_ISSUE) this._activeIssue = null; - if (event.key === STORAGE_KEYS.START_TIME) this._startTime = null; - } else if (event.type === 'removeMultiple') { - if (event.keys.includes(STORAGE_KEYS.ACTIVE_ISSUE)) this._activeIssue = null; - if (event.keys.includes(STORAGE_KEYS.START_TIME)) this._startTime = null; - } - this._sendUpdate(); - }); - - this.dispose = () => unsubscribe(); - } - - /** @param {vscode.WebviewView} webviewView */ - resolveWebviewView(webviewView) { - this._view = webviewView; - - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [vscode.Uri.joinPath(this._context.extensionUri, 'dist')], - }; - - webviewView.webview.html = this._getHtml(webviewView.webview); - - webviewView.webview.onDidReceiveMessage((message) => { - if (message.type === 'stop') { - vscode.commands.executeCommand('octoclock.stopTimer'); - } else if (message.type === 'openMyIssues') { - vscode.commands.executeCommand('octoclock.myIssues.focus'); - } - }); - - // Restore state from storage for the case where the webview was - // created after the timer was already running (e.g. after restart). - StorageService.getMultiple([STORAGE_KEYS.ACTIVE_ISSUE, STORAGE_KEYS.START_TIME]) - .then((values) => { - this._activeIssue = values[STORAGE_KEYS.ACTIVE_ISSUE] ?? null; - this._startTime = values[STORAGE_KEYS.START_TIME] ?? null; - this._sendUpdate(); - }) - .catch(() => { - // StorageService not ready — remain in idle state. - }); - } - - _sendUpdate() { - if (!this._view) return; - const running = !!(this._activeIssue && this._startTime); - const parts = this._activeIssue ? this._activeIssue.split('/') : []; - // ACTIVE_ISSUE path format: /owner/repo/issues/42 - // split('/') → ['', 'owner', 'repo', 'issues', '42'] - const issueNumber = parts.length >= 5 ? parts[4] : '?'; - const repo = parts.length >= 3 ? `${parts[1]}/${parts[2]}` : ''; - this._view.webview.postMessage({ - type: 'timerUpdate', - payload: { running, issueNumber, repo, startTime: this._startTime }, - }); - } - - /** @param {vscode.Webview} webview */ - _getHtml(webview) { - const nonce = getNonce(); - const csp = buildCsp(nonce, webview); - const tokensUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview', 'tokens.css'), - ); - const codiconsUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'fonts', 'codicon.ttf'), - ); - - return /* html */ ` - - - - - - - - Active Timer - - - -
- - No active timer — start from My Issues -
- - - -`; - } + } } diff --git a/packages/vscode-ext/src/webview/sidebar/active-timer/view/ActiveTimerPanel.jsx b/packages/vscode-ext/src/webview/sidebar/active-timer/view/ActiveTimerPanel.jsx new file mode 100644 index 0000000..bf800a8 --- /dev/null +++ b/packages/vscode-ext/src/webview/sidebar/active-timer/view/ActiveTimerPanel.jsx @@ -0,0 +1,94 @@ +// packages/vscode-ext/src/webview/sidebar/active-timer/view/ActiveTimerPanel.jsx +// +// Root component for the Active Timer sidebar panel. +// +// Receives timerUpdate messages from the host and renders: +// - A running row (dot + repo + issue + elapsed timer + stop button) +// - An idle row ("No active timer — start from My Issues") +// +// CSS classes are defined in html.js's + My Issues + + +
+ + +`; +} diff --git a/packages/vscode-ext/src/webview/sidebar/my-issues/provider.js b/packages/vscode-ext/src/webview/sidebar/my-issues/provider.js index e2bd383..528691a 100644 --- a/packages/vscode-ext/src/webview/sidebar/my-issues/provider.js +++ b/packages/vscode-ext/src/webview/sidebar/my-issues/provider.js @@ -27,8 +27,7 @@ import * as vscode from 'vscode'; import { IssueStorageService } from '../../../../../core/src/services/issue-storage.service.js'; import { StorageService } from '../../../../../core/src/services/storage.service.js'; import { STORAGE_KEYS } from '../../../../../core/src/utils/constants.utils.js'; -import { buildCsp } from '../../csp.js'; -import { getNonce } from '../../nonce.js'; +import { getHtml } from './html.js'; /** Issue URL path pattern, e.g. /owner/repo/issues/42 */ const ISSUE_URL_RE = /^\/[^/]+\/[^/]+\/issues\/\d+$/; @@ -41,476 +40,153 @@ const ISSUE_URL_RE = /^\/[^/]+\/[^/]+\/issues\/\d+$/; * @returns {{ id: number, title: string, status: string, repo: string, url: string }} */ function mapEntry(entry) { - const parts = entry.url.split('/'); - // /owner/repo/issues/42 → ['', 'owner', 'repo', 'issues', '42'] - const id = parts.length >= 5 ? parseInt(parts[4], 10) : 0; - const repo = parts.length >= 3 ? `${parts[1]}/${parts[2]}` : ''; + const parts = entry.url.split('/'); + // /owner/repo/issues/42 → ['', 'owner', 'repo', 'issues', '42'] + const id = parts.length >= 5 ? parseInt(parts[4], 10) : 0; + const repo = parts.length >= 3 ? `${parts[1]}/${parts[2]}` : ''; - const titleParts = entry.title.split(' | '); - const human = titleParts.length >= 3 ? titleParts.slice(1, -1).join(' | ') : entry.title; + const titleParts = entry.title.split(' | '); + const human = titleParts.length >= 3 ? titleParts.slice(1, -1).join(' | ') : entry.title; - return { id, title: human || entry.title, status: 'open', repo, url: entry.url }; + return { id, title: human || entry.title, status: 'open', repo, url: entry.url }; } export class MyIssuesProvider { - static viewType = 'octoclock.myIssues'; - - /** @type {vscode.WebviewView | undefined} */ - _view = undefined; - - /** @type {string | null} */ - _activeIssue = null; - - /** @type {string | null} */ - _startTime = null; - - /** - * @param {vscode.ExtensionContext} context - * @param {import('../../../../../core/src/ports/storage-events.port.js').StorageEventsPort} events - */ - constructor(context, events) { - this._context = context; - - const unsubscribe = events.subscribe((event) => { - if (event.type === 'set') { - if (event.key === STORAGE_KEYS.ACTIVE_ISSUE) this._activeIssue = event.value ?? null; - if (event.key === STORAGE_KEYS.START_TIME) this._startTime = event.value ?? null; - if (event.key === STORAGE_KEYS.ISSUES) this._sendIssues(); - } else if (event.type === 'remove') { - if (event.key === STORAGE_KEYS.ACTIVE_ISSUE) this._activeIssue = null; - if (event.key === STORAGE_KEYS.START_TIME) this._startTime = null; - } else if (event.type === 'removeMultiple') { - if (event.keys.includes(STORAGE_KEYS.ACTIVE_ISSUE)) this._activeIssue = null; - if (event.keys.includes(STORAGE_KEYS.START_TIME)) this._startTime = null; - } - this._sendTimerState(); - }); - - this.dispose = () => unsubscribe(); - } - - /** @param {vscode.WebviewView} webviewView */ - resolveWebviewView(webviewView) { - this._view = webviewView; - - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [vscode.Uri.joinPath(this._context.extensionUri, 'dist')], - }; + static viewType = 'octoclock.myIssues'; + + /** @type {vscode.WebviewView | undefined} */ + _view = undefined; + + /** @type {string | null} */ + _activeIssue = null; + + /** @type {string | null} */ + _startTime = null; + + /** + * @param {vscode.ExtensionContext} context + * @param {import('../../../../../core/src/ports/storage-events.port.js').StorageEventsPort} events + */ + constructor(context, events) { + this._context = context; + + const unsubscribe = events.subscribe((event) => { + if (event.type === 'set') { + if (event.key === STORAGE_KEYS.ACTIVE_ISSUE) this._activeIssue = event.value ?? null; + if (event.key === STORAGE_KEYS.START_TIME) this._startTime = event.value ?? null; + if (event.key === STORAGE_KEYS.ISSUES) this._sendIssues(); + } else if (event.type === 'remove') { + if (event.key === STORAGE_KEYS.ACTIVE_ISSUE) this._activeIssue = null; + if (event.key === STORAGE_KEYS.START_TIME) this._startTime = null; + } else if (event.type === 'removeMultiple') { + if (event.keys.includes(STORAGE_KEYS.ACTIVE_ISSUE)) this._activeIssue = null; + if (event.keys.includes(STORAGE_KEYS.START_TIME)) this._startTime = null; + } + this._sendTimerState(); + }); - webviewView.webview.html = this._getHtml(webviewView.webview); + this.dispose = () => unsubscribe(); + } - webviewView.webview.onDidReceiveMessage((message) => { - if (message.type === 'ready') { - this._sendIssues(); - this._sendTimerState(); - } else if (message.type === 'startTimer') { - if (typeof message.url === 'string' && ISSUE_URL_RE.test(message.url)) { - vscode.commands.executeCommand('octoclock.startTimer', message.url); - } - } else if (message.type === 'stopTimer') { - vscode.commands.executeCommand('octoclock.stopTimer'); - } else if (message.type === 'openUrl') { - if (typeof message.url === 'string' && ISSUE_URL_RE.test(message.url)) { - vscode.env.openExternal(vscode.Uri.parse(`https://github.com${message.url}`)); - } - } - }); + /** @param {vscode.WebviewView} webviewView */ + resolveWebviewView(webviewView) { + this._view = webviewView; - // Re-push issues when workspace folders change. - // Issues are global (not workspace-scoped) so this is a mechanism wiring - // for future workspace-scoped filtering. - this._context.subscriptions.push(vscode.workspace.onDidChangeWorkspaceFolders(() => this._sendIssues())); + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this._context.extensionUri, 'dist')], + }; - // Restore timer state from storage (webview may open after timer started). - StorageService.getMultiple([STORAGE_KEYS.ACTIVE_ISSUE, STORAGE_KEYS.START_TIME]) - .then((values) => { - this._activeIssue = values[STORAGE_KEYS.ACTIVE_ISSUE] ?? null; - this._startTime = values[STORAGE_KEYS.START_TIME] ?? null; - this._sendTimerState(); - }) - .catch(() => { - // StorageService not ready — timer state will arrive via events. - }); + webviewView.webview.html = getHtml(webviewView.webview, this._context.extensionUri); - // Load and send initial issue list. + webviewView.webview.onDidReceiveMessage((message) => { + if (message.type === 'ready') { this._sendIssues(); - - // Arm branch suggestion row if git API is available. - this._wireBranchSuggestion(); - } - - /** Load issues from storage and post them to the webview. */ - _sendIssues() { - if (!this._view) return; - IssueStorageService.getAll() - .then((entries) => { - this._view?.webview.postMessage({ - type: 'issues', - items: entries.map(mapEntry), - }); - }) - .catch(() => { - // StorageService not ready — webview remains in loading state. - }); - } - - /** Post current timer state to the webview. */ - _sendTimerState() { - if (!this._view) return; - const running = !!(this._activeIssue && this._startTime); - const parts = this._activeIssue ? this._activeIssue.split('/') : []; - const activeIssueId = running && parts.length >= 5 ? parseInt(parts[4], 10) : null; - this._view.webview.postMessage({ type: 'timerState', running, activeIssueId }); - } - - /** - * Arm the branch suggestion row using the vscode.git extension API. - * Silently no-ops when the git extension is not available. - */ - _wireBranchSuggestion() { - const git = vscode.extensions.getExtension('vscode.git')?.exports?.getAPI(1); - if (!git) return; - - const send = () => { - const branch = git.repositories[0]?.state.HEAD?.name ?? null; - if (!branch) return; - const match = branch.match(/\b(\d{2,6})\b/); - if (match) { - this._view?.webview.postMessage({ - type: 'branchSuggestion', - issueId: parseInt(match[1], 10), - branch, - }); - } - }; - - // Immediate check for when the panel opens after branch is already set. - send(); - git.repositories[0]?.state.onDidChange(send); - } - - /** @param {vscode.Webview} webview */ - _getHtml(webview) { - const nonce = getNonce(); - const csp = buildCsp(nonce, webview); - const tokensUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview', 'tokens.css'), - ); - const codiconsUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'fonts', 'codicon.ttf'), - ); - - return /* html */ ` - - - - - - - - My Issues - - - - - - -
- -
- - - -
-
- -
Loading…
- - - -`; - } } diff --git a/packages/vscode-ext/src/webview/sidebar/my-issues/view/BranchRow.jsx b/packages/vscode-ext/src/webview/sidebar/my-issues/view/BranchRow.jsx new file mode 100644 index 0000000..63ed79c --- /dev/null +++ b/packages/vscode-ext/src/webview/sidebar/my-issues/view/BranchRow.jsx @@ -0,0 +1,38 @@ +// packages/vscode-ext/src/webview/sidebar/my-issues/view/BranchRow.jsx +// +// Branch suggestion row — shown when the git extension detects a branch +// whose name contains a number matching a tracked issue. +// +// CSS classes defined in html.js. + +import { h } from 'preact'; + +/** + * @param {{ + * branch: { issueId: number, branch: string, url: string, title: string }, + * timerRunning: boolean, + * activeIssueId: number|null, + * onTrack: (url: string) => void, + * }} props + */ +export function BranchRow({ branch, timerRunning, activeIssueId, onTrack }) { + const tracking = timerRunning && branch.issueId === activeIssueId; + return ( +
+ + {branch.branch} + + {'#' + branch.issueId + ' ' + branch.title} + + +
+ ); +} diff --git a/packages/vscode-ext/src/webview/sidebar/my-issues/view/FilterBar.jsx b/packages/vscode-ext/src/webview/sidebar/my-issues/view/FilterBar.jsx new file mode 100644 index 0000000..e8826a1 --- /dev/null +++ b/packages/vscode-ext/src/webview/sidebar/my-issues/view/FilterBar.jsx @@ -0,0 +1,52 @@ +// packages/vscode-ext/src/webview/sidebar/my-issues/view/FilterBar.jsx +// +// Search input + Open/Closed/All tab buttons. +// CSS classes defined in html.js. + +import { h } from 'preact'; +import { useRef } from 'preact/hooks'; + +const TABS = /** @type {const} */ (['open', 'closed', 'all']); + +/** + * @param {{ + * query: string, + * onQuery: (q: string) => void, + * tab: string, + * onTab: (t: string) => void, + * }} props + */ +export function FilterBar({ query, onQuery, tab, onTab }) { + const debounce = useRef(/** @type {ReturnType|null} */(null)); + + const handleInput = (/** @type {Event} */ e) => { + const value = /** @type {HTMLInputElement} */ (e.target).value; + if (debounce.current) clearTimeout(debounce.current); + debounce.current = setTimeout(() => onQuery(value), 120); + }; + + return ( +
+ +
+ {TABS.map((t) => ( + + ))} +
+
+ ); +} diff --git a/packages/vscode-ext/src/webview/sidebar/my-issues/view/IssueList.jsx b/packages/vscode-ext/src/webview/sidebar/my-issues/view/IssueList.jsx new file mode 100644 index 0000000..1159bc8 --- /dev/null +++ b/packages/vscode-ext/src/webview/sidebar/my-issues/view/IssueList.jsx @@ -0,0 +1,129 @@ +// packages/vscode-ext/src/webview/sidebar/my-issues/view/IssueList.jsx +// +// Filtered list of issue rows. Each row shows: +// - Status icon (open / closed / active timer) +// - Issue title with search highlight +// - On hover: start-timer button + open-in-GitHub button +// +// CSS classes defined in html.js. + +import { h } from 'preact'; + +/** + * Escape special HTML characters to prevent XSS. + * @param {string} s + * @returns {string} + */ +function escHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Wrap the first match of query in . Both inputs are HTML-escaped first. + * @param {string} text + * @param {string} query + * @returns {string} — safe HTML string + */ +function highlight(text, query) { + const escaped = escHtml(text); + if (!query) return escaped; + const escapedQuery = escHtml(query); + const idx = escaped.toLowerCase().indexOf(escapedQuery.toLowerCase()); + if (idx === -1) return escaped; + return ( + escaped.slice(0, idx) + + '' + + escaped.slice(idx, idx + escapedQuery.length) + + '' + + escaped.slice(idx + escapedQuery.length) + ); +} + +/** + * @param {{ + * issues: Array<{ id: number, title: string, status: string, repo: string, url: string }>, + * query: string, + * statusTab: string, + * timerRunning: boolean, + * activeIssueId: number|null, + * onStart: (url: string) => void, + * onStop: () => void, + * onOpen: (url: string) => void, + * }} props + */ +export function IssueList({ issues, query, statusTab, timerRunning, activeIssueId, onStart, onOpen }) { + const q = query.toLowerCase(); + + const filtered = issues.filter((issue) => { + const matchStatus = statusTab === 'all' || issue.status === statusTab; + const matchQuery = + !q || + issue.title.toLowerCase().includes(q) || + String(issue.id).includes(q.replace('#', '')); + return matchStatus && matchQuery; + }); + + if (filtered.length === 0) { + const msg = issues.length === 0 ? 'Loading\u2026' : 'No matching issues'; + return
{msg}
; + } + + return ( +
+ {filtered.map((issue) => { + const isActive = timerRunning && issue.id === activeIssueId; + const isClosed = issue.status === 'closed'; + const iconColor = isActive + ? 'var(--oc-timer)' + : isClosed + ? 'var(--oc-muted)' + : 'var(--oc-open)'; + const iconName = isActive + ? 'codicon-clock' + : isClosed + ? 'codicon-issue-closed' + : 'codicon-issue-opened'; + + // dangerouslySetInnerHTML is safe here — highlight() HTML-escapes + // both text and query before inserting . + const labelHtml = `#${issue.id}  ${highlight(issue.title, query)}`; + + return ( +
+ + {/* eslint-disable-next-line react/no-danger */} + +
+ {!isClosed && ( + + )} + +
+
+ ); + })} +
+ ); +} diff --git a/packages/vscode-ext/src/webview/sidebar/my-issues/view/MyIssuesPanel.jsx b/packages/vscode-ext/src/webview/sidebar/my-issues/view/MyIssuesPanel.jsx new file mode 100644 index 0000000..183cd0f --- /dev/null +++ b/packages/vscode-ext/src/webview/sidebar/my-issues/view/MyIssuesPanel.jsx @@ -0,0 +1,85 @@ +// packages/vscode-ext/src/webview/sidebar/my-issues/view/MyIssuesPanel.jsx +// +// Root component for the My Issues sidebar panel. +// +// Coordinates: +// - issues list + filter state (query, statusTab) +// - timer state (running, activeIssueId) +// - branch suggestion row +// +// All host communication goes through useVscodeMessage. +// CSS classes are defined in html.js's