@openforge-app/plugin-sdk 0.2.9 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.js CHANGED
@@ -34,7 +34,6 @@ const OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS = [
34
34
  'config',
35
35
  'projectConfig',
36
36
  'browserSurfaces',
37
- 'taskLinks',
38
37
  'appEnablement',
39
38
  'customSidebarNavigation',
40
39
  'reviewUI',
@@ -71,6 +70,7 @@ export class TaskFollowUpError extends Error {
71
70
  this.code = code;
72
71
  }
73
72
  }
73
+ export const MAX_AGENT_SESSION_PAGE_SIZE = 250;
74
74
  export function makePluginViewKey(pluginId, viewId) {
75
75
  return `plugin:${pluginId}:${viewId}`;
76
76
  }
@@ -4,12 +4,15 @@
4
4
 
5
5
  export type ModalInitialFocus = HTMLElement | string | (() => HTMLElement | null | undefined) | null | undefined
6
6
 
7
+ type ModalAccessibleName =
8
+ | { ariaLabel: string; ariaLabelledby?: never }
9
+ | { ariaLabel?: never; ariaLabelledby: string }
10
+
7
11
  interface Props {
8
12
  onClose: () => void
9
13
  maxWidth?: string
10
14
  overflowVisible?: boolean
11
15
  initialFocus?: ModalInitialFocus
12
- ariaLabel?: string
13
16
  showHeader?: boolean
14
17
  closeLabel?: string
15
18
  closeDisabled?: boolean
@@ -21,7 +24,34 @@
21
24
  children: Snippet
22
25
  }
23
26
 
24
- let { onClose, maxWidth = '500px', overflowVisible = false, initialFocus, ariaLabel, showHeader = true, closeLabel = 'Close dialog', closeDisabled = false, onKeydown, testId, modalClass = '', boxClass = '', header, children }: Props = $props()
27
+ let { onClose, maxWidth = '500px', overflowVisible = false, initialFocus, ariaLabel, ariaLabelledby, showHeader = true, closeLabel = 'Close dialog', closeDisabled = false, onKeydown, testId, modalClass = '', boxClass = '', header, children }: Props & ModalAccessibleName = $props()
28
+ let accessibleNameAttributes = $derived.by(() => {
29
+ const hasAriaLabel = Boolean(ariaLabel?.trim())
30
+ const hasAriaLabelledby = Boolean(ariaLabelledby?.trim())
31
+
32
+ if (hasAriaLabel === hasAriaLabelledby) {
33
+ throw new Error('Modal requires exactly one non-empty accessible name prop: ariaLabel or ariaLabelledby')
34
+ }
35
+
36
+ return {
37
+ ariaLabel: hasAriaLabel ? ariaLabel : undefined,
38
+ ariaLabelledby: hasAriaLabelledby ? ariaLabelledby : undefined,
39
+ }
40
+ })
41
+
42
+ $effect(() => {
43
+ const idReferences = accessibleNameAttributes.ariaLabelledby?.split(/\s+/)
44
+ if (!idReferences) return
45
+
46
+ const hasNamingText = idReferences.some((id) => {
47
+ const labelledElement = document.getElementById(id)
48
+ return Boolean(labelledElement?.textContent?.trim() || labelledElement?.getAttribute('aria-label')?.trim())
49
+ })
50
+
51
+ if (!hasNamingText) {
52
+ throw new Error('Modal ariaLabelledby must reference at least one element with naming text')
53
+ }
54
+ })
25
55
 
26
56
  let modalElement: HTMLDivElement | null = $state(null)
27
57
  let hasAppliedInitialFocus = false
@@ -142,7 +172,7 @@
142
172
  }
143
173
  </script>
144
174
 
145
- <div bind:this={modalElement} class="modal modal-open {modalClass}" data-testid={testId} onclick={handleOverlayClick} onkeydown={handleKeydown} role="dialog" aria-modal="true" aria-label={ariaLabel} tabindex="-1">
175
+ <div bind:this={modalElement} class="modal modal-open {modalClass}" data-testid={testId} onclick={handleOverlayClick} onkeydown={handleKeydown} role="dialog" aria-modal="true" aria-label={accessibleNameAttributes.ariaLabel} aria-labelledby={accessibleNameAttributes.ariaLabelledby} tabindex="-1">
146
176
  <div class="modal-box bg-base-100 shadow-xl p-0 flex flex-col max-h-[90vh] {overflowVisible ? 'overflow-visible' : ''} {boxClass}" style="max-width: {maxWidth}">
147
177
  {#if showHeader}
148
178
  <div class="flex items-center justify-between px-5 py-4 border-b border-base-300">
@@ -5,17 +5,24 @@
5
5
  title: string
6
6
  subtitle?: string | null
7
7
  surface?: 'default' | 'subtle'
8
+ headingLevel?: 'h1' | 'h2'
8
9
  actions?: Snippet
9
10
  }
10
11
 
11
- let { title, subtitle = null, surface = 'subtle', actions }: Props = $props()
12
+ let {
13
+ title,
14
+ subtitle = null,
15
+ surface = 'default',
16
+ headingLevel = 'h1',
17
+ actions,
18
+ }: Props = $props()
12
19
  </script>
13
20
 
14
- <header class="flex items-center justify-between gap-4 border-b border-base-300 px-6 py-3 shrink-0 {surface === 'default' ? 'bg-base-100' : 'bg-base-200'}">
21
+ <header class="flex min-h-13 shrink-0 flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-base-300 px-4 py-2 sm:px-6 {surface === 'default' ? 'bg-base-100' : 'bg-base-200'}">
15
22
  <div class="min-w-0">
16
- <h2 class="text-[22px] font-semibold text-base-content tracking-tight m-0 truncate">{title}</h2>
23
+ <svelte:element this={headingLevel} class="m-0 truncate text-base font-semibold leading-5 tracking-[-0.01em] text-base-content">{title}</svelte:element>
17
24
  {#if subtitle}
18
- <p class="text-[13px] text-secondary mt-0.5 m-0 truncate">{subtitle}</p>
25
+ <p class="m-0 mt-0.5 truncate text-[13px] leading-5 text-base-content/60">{subtitle}</p>
19
26
  {/if}
20
27
  </div>
21
28
  {#if actions}
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+
4
+ interface Props {
5
+ header: Snippet
6
+ children: Snippet
7
+ class?: string
8
+ }
9
+
10
+ let { header, children, class: className = '' }: Props = $props()
11
+ </script>
12
+
13
+ <div class="flex h-full min-h-0 flex-col overflow-hidden bg-base-100 text-base-content {className}">
14
+ <div class="shrink-0">
15
+ {@render header()}
16
+ </div>
17
+ <div class="flex min-h-0 flex-1 flex-col overflow-hidden">
18
+ {@render children()}
19
+ </div>
20
+ </div>
@@ -0,0 +1,192 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte'
3
+ import FileTypeIcon from './FileTypeIcon.svelte'
4
+ import {
5
+ buildProjectFileTree,
6
+ flattenVisibleProjectFileTree,
7
+ formatProjectFileTreeSize,
8
+ getProjectFileTreeDepth,
9
+ getProjectFileTreeItemAccessibility,
10
+ getProjectFileTreeKeyboardAction,
11
+ projectFileTreePathToId,
12
+ type ProjectFileTreeNode,
13
+ } from '../projectFileTree'
14
+ import type { FileEntry } from '../domain'
15
+
16
+ interface Props {
17
+ entries: FileEntry[]
18
+ expandedDirs: Set<string>
19
+ selectedPath: string | null
20
+ onToggleDir: (path: string) => void
21
+ onSelectFile: (path: string) => void
22
+ initialScrollTop?: number
23
+ onScrollTopChange?: (scrollTop: number) => void
24
+ focusSelectedRequest?: number | null
25
+ }
26
+
27
+ type TreeNode = ProjectFileTreeNode<FileEntry>
28
+
29
+ const {
30
+ entries,
31
+ expandedDirs,
32
+ selectedPath,
33
+ onToggleDir,
34
+ onSelectFile,
35
+ initialScrollTop = 0,
36
+ onScrollTopChange,
37
+ focusSelectedRequest = null,
38
+ }: Props = $props()
39
+
40
+ let scrollContainer = $state<HTMLDivElement | null>(null)
41
+ let appliedInitialScrollTop = $state<number | null>(null)
42
+ let focusedPath = $state<string | null>(null)
43
+ let lastSelectedPath = $state<string | null>(null)
44
+ let appliedFocusSelectedRequest = $state<number | null>(null)
45
+
46
+ const treeNodes = $derived(buildProjectFileTree(entries))
47
+ const visibleNodes = $derived(flattenVisibleProjectFileTree(treeNodes, expandedDirs))
48
+ const visiblePaths = $derived(visibleNodes.map((node) => node.entry.path))
49
+
50
+ function getTreeItemElement(path: string): HTMLElement | null {
51
+ const index = visiblePaths.indexOf(path)
52
+ if (index === -1) return null
53
+ return scrollContainer?.querySelector<HTMLElement>(`[data-tree-index="${index}"]`) ?? null
54
+ }
55
+
56
+ async function focusPath(path: string) {
57
+ focusedPath = path
58
+ await tick()
59
+ getTreeItemElement(path)?.focus()
60
+ }
61
+
62
+ function activateNode(node: TreeNode) {
63
+ if (node.entry.isDir) {
64
+ void focusPath(node.entry.path)
65
+ onToggleDir(node.entry.path)
66
+ } else {
67
+ onSelectFile(node.entry.path)
68
+ }
69
+ }
70
+
71
+ function handleKeydown(event: KeyboardEvent, node: TreeNode) {
72
+ const action = getProjectFileTreeKeyboardAction(event, node, { expandedDirs, visiblePaths })
73
+ if (!action.handled) return
74
+
75
+ event.preventDefault()
76
+ event.stopPropagation()
77
+
78
+ switch (action.type) {
79
+ case 'activate':
80
+ activateNode(node)
81
+ break
82
+ case 'focus':
83
+ void focusPath(action.path)
84
+ break
85
+ case 'toggle':
86
+ onToggleDir(action.path)
87
+ break
88
+ case 'none':
89
+ break
90
+ }
91
+ }
92
+
93
+ function handleScroll() {
94
+ if (scrollContainer) {
95
+ onScrollTopChange?.(scrollContainer.scrollTop)
96
+ }
97
+ }
98
+
99
+ $effect(() => {
100
+ if (scrollContainer && appliedInitialScrollTop !== initialScrollTop) {
101
+ scrollContainer.scrollTop = initialScrollTop
102
+ appliedInitialScrollTop = initialScrollTop
103
+ }
104
+ })
105
+
106
+ $effect(() => {
107
+ const selectedChanged = selectedPath !== lastSelectedPath
108
+
109
+ if (selectedChanged && selectedPath !== null && visiblePaths.includes(selectedPath)) {
110
+ focusedPath = selectedPath
111
+ } else if (focusedPath === null || !visiblePaths.includes(focusedPath)) {
112
+ focusedPath = selectedPath !== null && visiblePaths.includes(selectedPath) ? selectedPath : visiblePaths[0] ?? null
113
+ }
114
+
115
+ lastSelectedPath = selectedPath
116
+ })
117
+
118
+ $effect(() => {
119
+ if (focusSelectedRequest === null || appliedFocusSelectedRequest === focusSelectedRequest) return
120
+ appliedFocusSelectedRequest = focusSelectedRequest
121
+ if (selectedPath !== null && visiblePaths.includes(selectedPath)) {
122
+ void focusPath(selectedPath)
123
+ }
124
+ })
125
+ </script>
126
+
127
+ <div class="flex h-full flex-col border-r border-base-300 bg-base-100">
128
+ <div
129
+ class="flex-1 overflow-y-auto py-2"
130
+ bind:this={scrollContainer}
131
+ onscroll={handleScroll}
132
+ role="tree"
133
+ aria-label="Project files"
134
+ >
135
+ {#snippet renderNodes(nodes: TreeNode[])}
136
+ {#each nodes as node (node.entry.path)}
137
+ {@const entry = node.entry}
138
+ {@const isExpanded = expandedDirs.has(entry.path)}
139
+ {@const isSelected = selectedPath === entry.path}
140
+ {@const treeIndex = visiblePaths.indexOf(entry.path)}
141
+ {@const labelId = `${projectFileTreePathToId(entry.path)}-label`}
142
+ {@const sizeId = `${projectFileTreePathToId(entry.path)}-size`}
143
+ {@const a11y = getProjectFileTreeItemAccessibility(node, { expandedDirs, selectedPath, labelId, sizeId })}
144
+ <div
145
+ class="outline-none [&:focus-visible>div:first-child]:ring-2 [&:focus-visible>div:first-child]:ring-inset [&:focus-visible>div:first-child]:ring-primary/60"
146
+ role="treeitem"
147
+ tabindex={focusedPath === entry.path ? 0 : -1}
148
+ aria-level={a11y.level}
149
+ aria-setsize={a11y.setSize}
150
+ aria-posinset={a11y.posInSet}
151
+ aria-expanded={a11y.expanded}
152
+ aria-current={a11y.current}
153
+ aria-selected={a11y.selected}
154
+ aria-labelledby={a11y.labelledBy}
155
+ data-testid="tree-entry"
156
+ data-tree-index={treeIndex}
157
+ onclick={(event) => {
158
+ event.stopPropagation()
159
+ activateNode(node)
160
+ }}
161
+ onkeydown={(event) => handleKeydown(event, node)}
162
+ onfocus={() => {
163
+ focusedPath = entry.path
164
+ }}
165
+ >
166
+ <div
167
+ class="w-full flex items-center gap-2 text-xs cursor-pointer transition-colors py-1.5 pr-3 {entry.isDir ? 'text-base-content hover:bg-base-content/5' : isSelected ? 'bg-primary/10 text-primary font-medium border-l-2 border-l-primary hover:bg-primary/15' : 'text-base-content hover:bg-base-content/5'}"
168
+ style="padding-left: {entry.isDir || !isSelected ? 12 + getProjectFileTreeDepth(entry.path) * 16 : 10 + getProjectFileTreeDepth(entry.path) * 16}px"
169
+ >
170
+ {#if entry.isDir}
171
+ <span class="text-[0.6rem] text-base-content/50 shrink-0" data-testid={`dir-indicator-${entry.path}`} aria-hidden="true">{isExpanded ? '▼' : '▶'}</span>
172
+ <FileTypeIcon folder open={isExpanded} class="w-3.5 h-3.5" />
173
+ <span id={labelId} class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left" data-testid="entry-label">{entry.name}/</span>
174
+ {:else}
175
+ <FileTypeIcon filename={entry.path} class="w-3.5 h-3.5" />
176
+ <span id={labelId} class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left" data-testid="entry-label">{entry.name}</span>
177
+ <span id={sizeId} class="text-base-content/50 text-[0.7rem] ml-auto">{formatProjectFileTreeSize(entry.size)}</span>
178
+ {/if}
179
+ </div>
180
+
181
+ {#if entry.isDir && isExpanded && node.children.length > 0}
182
+ <div role="group">
183
+ {@render renderNodes(node.children)}
184
+ </div>
185
+ {/if}
186
+ </div>
187
+ {/each}
188
+ {/snippet}
189
+
190
+ {@render renderNodes(treeNodes)}
191
+ </div>
192
+ </div>
package/dist/vite.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * plugin://host-runtime assets are derived from the same host-runtime contract.
5
5
  */
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
- import { OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS } from './publicUiExports.mjs';
7
+ import { OPENFORGE_PLUGIN_SDK_PUBLIC_ENTRYPOINTS } from './publicEntrypoints.mjs';
8
8
  import { OPENFORGE_HOST_RUNTIME_SVELTE_SPECIFIERS as HOST_RUNTIME_SVELTE_SPECIFIERS } from './svelteHostRuntimeContract.mjs';
9
9
  export const OPENFORGE_HOST_RUNTIME_SVELTE_SPECIFIERS = HOST_RUNTIME_SVELTE_SPECIFIERS;
10
10
  export const OPENFORGE_HOST_SHARED_SVELTE_IMPORTS = OPENFORGE_HOST_RUNTIME_SVELTE_SPECIFIERS;
@@ -26,22 +26,12 @@ export function isOpenForgeHostRuntimeExternal(id) {
26
26
  }
27
27
  export const openforgePluginViteExternals = isOpenForgeHostRuntimeExternal;
28
28
  const OPENFORGE_PLUGIN_SDK_SOURCE_ENTRYPOINTS = Object.freeze([
29
- ['@openforge-app/plugin-sdk/frontend', 'packages/plugin-sdk/src/frontend.ts'],
30
- ['@openforge-app/plugin-sdk/backend', 'packages/plugin-sdk/src/backend.ts'],
31
- ['@openforge-app/plugin-sdk/testing', 'packages/plugin-sdk/src/testing.ts'],
32
- ['@openforge-app/plugin-sdk/vite', 'packages/plugin-sdk/src/vite.ts'],
33
- ['@openforge-app/plugin-sdk/package-metadata-schema.json', 'packages/plugin-sdk/src/openforgePackageMetadataSchema.json'],
34
- ['@openforge-app/plugin-sdk/domain', 'packages/plugin-sdk/src/domain.ts'],
35
- ['@openforge-app/plugin-sdk/prStatusPresentation', 'packages/plugin-sdk/src/prStatusPresentation.ts'],
36
- ['@openforge-app/plugin-sdk/markdown', 'packages/plugin-sdk/src/markdown.ts'],
37
- ['@openforge-app/plugin-sdk/numberParsing', 'packages/plugin-sdk/src/numberParsing.ts'],
38
- ['@openforge-app/plugin-sdk/projectFileTree', 'packages/plugin-sdk/src/projectFileTree.ts'],
39
- ['@openforge-app/plugin-sdk/sanitize', 'packages/plugin-sdk/src/sanitize.ts'],
40
- ['@openforge-app/plugin-sdk/pluginIcons', 'packages/plugin-sdk/src/pluginIcons.ts'],
41
- ['@openforge-app/plugin-sdk/fileIcons', 'packages/plugin-sdk/src/fileIcons.ts'],
42
- ['@openforge-app/plugin-sdk/collapsibleSectionState', 'packages/plugin-sdk/src/collapsibleSectionState.ts'],
43
- ...OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS.map(({ importSpecifier, workspaceSourcePath }) => [importSpecifier, workspaceSourcePath]),
44
- ['@openforge-app/plugin-sdk', 'packages/plugin-sdk/src/index.ts'],
29
+ ...OPENFORGE_PLUGIN_SDK_PUBLIC_ENTRYPOINTS
30
+ .filter(({ packageSubpath }) => packageSubpath !== '.')
31
+ .map(({ importSpecifier, workspaceSourcePath }) => [importSpecifier, workspaceSourcePath]),
32
+ ...OPENFORGE_PLUGIN_SDK_PUBLIC_ENTRYPOINTS
33
+ .filter(({ packageSubpath }) => packageSubpath === '.')
34
+ .map(({ importSpecifier, workspaceSourcePath }) => [importSpecifier, workspaceSourcePath]),
45
35
  ]);
46
36
  function repoRootUrl(repoRoot) {
47
37
  if (repoRoot instanceof URL) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openforge-app/plugin-sdk",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -68,15 +68,21 @@
68
68
  "types": "./dist/collapsibleSectionState.d.ts",
69
69
  "default": "./dist/collapsibleSectionState.js"
70
70
  },
71
+ "./taskBrowserDevToolsShortcuts": {
72
+ "types": "./dist/taskBrowserDevToolsShortcuts.d.ts",
73
+ "default": "./dist/taskBrowserDevToolsShortcuts.js"
74
+ },
71
75
  "./ui/Button.svelte": "./dist/ui/Button.svelte",
72
76
  "./ui/Checkbox.svelte": "./dist/ui/Checkbox.svelte",
73
77
  "./ui/MarkdownContent.svelte": "./dist/ui/MarkdownContent.svelte",
74
78
  "./ui/ResizablePanel.svelte": "./dist/ui/ResizablePanel.svelte",
75
79
  "./ui/Modal.svelte": "./dist/ui/Modal.svelte",
76
80
  "./ui/PluginPageHeader.svelte": "./dist/ui/PluginPageHeader.svelte",
81
+ "./ui/PluginPageShell.svelte": "./dist/ui/PluginPageShell.svelte",
77
82
  "./ui/PluginViewState.svelte": "./dist/ui/PluginViewState.svelte",
78
83
  "./ui/PluginSidebarLink.svelte": "./dist/ui/PluginSidebarLink.svelte",
79
84
  "./ui/FileTypeIcon.svelte": "./dist/ui/FileTypeIcon.svelte",
85
+ "./ui/ProjectFileTree.svelte": "./dist/ui/ProjectFileTree.svelte",
80
86
  "./ui/CollapsibleSection.svelte": "./dist/ui/CollapsibleSection.svelte"
81
87
  },
82
88
  "files": [
@@ -104,7 +110,8 @@
104
110
  },
105
111
  "scripts": {
106
112
  "clean": "node -e \"import('node:fs').then(({ rmSync }) => rmSync('dist', { recursive: true, force: true }))\"",
107
- "build": "pnpm run clean && tsc && pnpm run build:assets",
113
+ "check:entrypoints": "node ./scripts/check-entrypoint-registries.mjs",
114
+ "build": "pnpm run check:entrypoints && pnpm run clean && tsc && pnpm run build:assets",
108
115
  "build:assets": "node ./scripts/copy-package-assets.mjs",
109
116
  "check:contract": "node ./scripts/check-published-contract.mjs",
110
117
  "icons:generate": "node ./scripts/generate-file-type-icons.mjs",