Skip to content

Commit 4f64284

Browse files
committed
feat: migrate messages and assets onto the wire services
- messages: drops its commonRpcFunctions registration for a devframes:plugin:messages:open-file bridge that resolves workspace-relative file positions server-side and delegates to @devframes/service-open (declared in the definition's services); the panel gates its affordance on the service advertisement - assets: open-in-editor / reveal-in-folder delegate to service-open, installed at setup with the managed dir as an extra allowed root; the SPA hides both buttons until the service is advertised, and text-asset previews render server-highlighted through @devframes/service-shiki (declared optional) with the plain <pre> as fallback - docs + skill point at the service over the deprecated recipes
1 parent fc59de5 commit 4f64284

25 files changed

Lines changed: 278 additions & 43 deletions

File tree

docs/helpers/common-rpc-functions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ outline: deep
44

55
# Common RPC Functions
66

7+
> [!WARNING]
8+
> Deprecated in favor of the [`@devframes/service-open` wire service](/guide/services#built-in-services) — one host-level installation shared by every plugin, feature-detectable from clients, with workspace-root path containment on top of the editor gating. The recipe keeps working; removal lands in a future major.
9+
710
Prebuilt RPC actions for the two file-system actions every CLI devtool needs — opening a file in the editor, revealing a path in the OS file explorer. Use the recipe instead of re-implementing them so every devframe converges on the same registered names and payload shape.
811

912
```ts

docs/plugins/assets.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,12 @@ All functions are namespaced `devframes:plugin:assets:*`:
9595
| `list` | `query`, `snapshot: true` | Every file under the managed directory, with type, size, and last-modified time. |
9696
| `capabilities` | `query`, `snapshot: true` | Whether write actions are enabled, and the upload allow-list — lets the UI gate itself proactively. |
9797
| `read-image-meta` | `query` | Width, height, and orientation for an image asset. |
98-
| `read-text` | `query` | Truncated text content, for preview. |
98+
| `read-text` | `query` | Truncated text content, for preview. When the host advertises the [`@devframes/service-shiki` wire service](/guide/services#built-in-services), the panel renders it server-highlighted; otherwise it falls back to a plain `<pre>`. |
9999
| `upload` | `action` | Allocates a streaming upload slot; the client pipes the file's bytes over the paired channel. |
100100
| `rename` | `action` | Renames an asset within its folder, preserving its extension. |
101101
| `delete` | `action` | Deletes one or more assets in a single call. |
102102
| `mkdir` | `action` | Creates a folder, including missing parents. |
103-
| `open-in-editor` / `reveal-in-folder` | `action` | Launch the asset in your editor, or reveal its containing folder in the OS file manager. Always registered, regardless of `write`. |
103+
| `open-in-editor` / `reveal-in-folder` | `action` | Launch the asset in your editor, or reveal its containing folder in the OS file manager, delegating to the [`@devframes/service-open` wire service](/guide/services#built-in-services) (installed by the plugin with the managed dir as an allowed root). Always registered, regardless of `write`. |
104104

105105
`upload` / `rename` / `delete` / `mkdir` are registered only when `write` is enabled.
106106

plugins/assets/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,23 @@
4848
},
4949
"peerDependencies": {
5050
"@devframes/plugin-assets--assets": "workspace:*",
51+
"@devframes/service-shiki": "workspace:*",
5152
"devframe": "workspace:*",
5253
"vite": "^7.0.0 || ^8.0.0"
5354
},
5455
"peerDependenciesMeta": {
5556
"@devframes/plugin-assets--assets": {
5657
"optional": true
5758
},
59+
"@devframes/service-shiki": {
60+
"optional": true
61+
},
5862
"vite": {
5963
"optional": true
6064
}
6165
},
6266
"dependencies": {
67+
"@devframes/service-open": "workspace:*",
6368
"cac": "catalog:deps",
6469
"chokidar": "catalog:deps",
6570
"image-meta": "catalog:deps",
@@ -71,6 +76,7 @@
7176
"devDependencies": {
7277
"@antfu/design": "catalog:frontend",
7378
"@devframes/plugin-assets--assets": "workspace:*",
79+
"@devframes/service-shiki": "workspace:*",
7480
"@devframes/vite": "workspace:*",
7581
"@iconify-json/ph": "catalog:frontend",
7682
"@storybook/addon-a11y": "catalog:storybook",

plugins/assets/src/diagnostics.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,9 @@ export const diagnostics = defineDiagnostics({
3434
why: 'The upload streaming channel is unavailable because this devframe was set up with `write: false`.',
3535
fix: 'This indicates an internal registration bug — `upload` should never be reachable without `write: true`. Please report it.',
3636
},
37+
DP_ASSETS_0008: {
38+
why: 'Cannot open the asset: the "@devframes/service-open" wire service is not installed on this host.',
39+
fix: 'Install the service package next to the assets plugin (it installs it during setup), or install it host-side via `ctx.services.install(createOpenService())`.',
40+
},
3741
},
3842
})

plugins/assets/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,20 @@ export function createAssetsDevframe(options: AssetsDevframeOptions = {}): Devfr
124124
},
125125
},
126126
dock: { category: '~builtin' },
127+
// Server-highlighted text previews; the SPA falls back to a plain
128+
// `<pre>` when the service isn't advertised.
129+
services: [{ package: '@devframes/service-shiki' }],
127130
async setup(ctx, info) {
128131
const readOnlyFlag = info?.flags?.readOnly === true
129132
const dir = options.dir ? resolve(ctx.cwd, options.dir) : resolve(ctx.cwd, 'public')
133+
// Backs open-in-editor / reveal-in-folder. Installed here (not in the
134+
// declarative `services` list) because the managed dir — contributed
135+
// as an extra allowed root, since it may live outside the workspace —
136+
// is only known at setup time.
137+
void ctx.services.install(
138+
{ package: '@devframes/service-open', options: { roots: [dir] } },
139+
{ resolveFrom: pkg.name },
140+
)
130141
await setupAssets(ctx, {
131142
dir,
132143
write: readOnlyFlag ? false : write,

plugins/assets/src/rpc/functions/open-in-editor.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import type { DevframeNodeContext } from 'devframe'
2+
import { OPEN_SERVICE_PACKAGE } from '@devframes/service-open'
23
import { createDefineWrapperWithContext } from 'devframe/rpc'
3-
import { launchEditor } from 'devframe/utils/launch-editor'
44
import { s } from 'devframe/utils/simple-schema'
5+
import { diagnostics } from '../../diagnostics'
56
import { getAssetsContext } from '../../node/context'
67

78
const defineAssetsRpc = createDefineWrapperWithContext<DevframeNodeContext>()
89

910
/**
10-
* Reuses devframe's `launchEditor` utility (the same one backing the core
11-
* `devframe:open-in-editor` recipe) but resolves the path against the
12-
* managed directory first, so the client only ever sends a root-relative
13-
* path — never the server's absolute filesystem layout.
11+
* Delegates to the `@devframes/service-open` wire service (installed by the
12+
* plugin's setup with the managed dir as an allowed root), resolving the
13+
* path against the managed directory firstso the client only ever sends
14+
* a root-relative path, never the server's absolute filesystem layout.
1415
*/
1516
export const openInEditor = defineAssetsRpc({
1617
name: 'devframes:plugin:assets:open-in-editor',
@@ -29,7 +30,10 @@ export const openInEditor = defineAssetsRpc({
2930
return {
3031
// See `list.ts` for why the async handler is cast.
3132
handler: (async (path: string): Promise<void> => {
32-
launchEditor(assets.resolvePath(path))
33+
const open = ctx.services.get(OPEN_SERVICE_PACKAGE)
34+
if (!open)
35+
throw diagnostics.DP_ASSETS_0008()
36+
await open.openInEditor({ path: assets.resolvePath(path) })
3337
}) as any,
3438
}
3539
},

plugins/assets/src/rpc/functions/reveal-in-folder.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import type { DevframeNodeContext } from 'devframe'
2+
import { OPEN_SERVICE_PACKAGE } from '@devframes/service-open'
23
import { createDefineWrapperWithContext } from 'devframe/rpc'
3-
import { open } from 'devframe/utils/open'
44
import { s } from 'devframe/utils/simple-schema'
55
import { dirname } from 'pathe'
6+
import { diagnostics } from '../../diagnostics'
67
import { getAssetsContext } from '../../node/context'
78

89
const defineAssetsRpc = createDefineWrapperWithContext<DevframeNodeContext>()
910

1011
/**
11-
* Reuses devframe's `open` utility (the same one backing the core
12-
* `devframe:open-in-finder` recipe), opening the asset's containing folder
13-
* so it's revealed in the OS file manager rather than launched with its
14-
* default app (which `download` already covers).
12+
* Delegates to the `@devframes/service-open` wire service's `openInFinder`,
13+
* opening the asset's containing folder so it's revealed in the OS file
14+
* manager rather than launched with its default app (which `download`
15+
* already covers).
1516
*/
1617
export const revealInFolder = defineAssetsRpc({
1718
name: 'devframes:plugin:assets:reveal-in-folder',
@@ -30,7 +31,10 @@ export const revealInFolder = defineAssetsRpc({
3031
return {
3132
// See `list.ts` for why the async handler is cast.
3233
handler: (async (path: string): Promise<void> => {
33-
await open(dirname(assets.resolvePath(path)))
34+
const open = ctx.services.get(OPEN_SERVICE_PACKAGE)
35+
if (!open)
36+
throw diagnostics.DP_ASSETS_0008()
37+
await open.openInFinder({ path: dirname(assets.resolvePath(path)) })
3438
}) as any,
3539
}
3640
},

plugins/assets/src/spa/app/components/AssetDetails.vue

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import FormTextInput from '@antfu/design/components/Form/FormTextInput.vue'
77
import OverlayModal from '@antfu/design/components/Overlay/OverlayModal.vue'
88
import { computed, ref, watch } from 'vue'
99
import { fileNameOf, formatFileSize, formatTimeAgo } from '../utils/format'
10+
import { highlightAsset } from '../utils/highlight'
1011
import { buildSnippets } from '../utils/snippets'
1112
import AssetPreview from './AssetPreview.vue'
1213
import CodeSnippets from './CodeSnippets.vue'
@@ -23,6 +24,7 @@ const SUPPORTS_PREVIEW = new Set(['image', 'text', 'video', 'audio', 'font'])
2324
2425
const imageMeta = ref<AssetImageMeta | null>(null)
2526
const textContent = ref<string | null>(null)
27+
const highlightedHtml = ref<string | null>(null)
2628
const deleteOpen = ref(false)
2729
const renameOpen = ref(false)
2830
const newName = ref('')
@@ -32,14 +34,38 @@ const errorNotice = ref<string | null>(null)
3234
watch(() => props.asset.path, (path) => {
3335
imageMeta.value = null
3436
textContent.value = null
37+
highlightedHtml.value = null
3538
errorNotice.value = null
3639
const rpc = props.rpc
3740
if (!rpc)
3841
return
3942
if (props.asset.type === 'image')
4043
void rpc.call('devframes:plugin:assets:read-image-meta', path).then((m) => { imageMeta.value = m })
41-
if (props.asset.type === 'text')
42-
void rpc.call('devframes:plugin:assets:read-text', path, 5000).then((c) => { textContent.value = c })
44+
if (props.asset.type === 'text') {
45+
void rpc.call('devframes:plugin:assets:read-text', path, 5000).then(async (c) => {
46+
textContent.value = c
47+
// Server-highlighted preview via the shiki wire service; `null` when
48+
// it isn't advertised — the preview keeps its plain `<pre>`.
49+
const html = c == null ? null : await highlightAsset(rpc, path, c)
50+
if (props.asset.path === path)
51+
highlightedHtml.value = html
52+
})
53+
}
54+
}, { immediate: true })
55+
56+
// The open/reveal affordances delegate to the `@devframes/service-open`
57+
// wire service; hide them until the host advertises it.
58+
const openServiceAvailable = ref(false)
59+
watch(() => props.rpc, (rpc) => {
60+
if (!rpc)
61+
return
62+
openServiceAvailable.value = rpc.services.has('@devframes/service-open')
63+
rpc.services.state()
64+
.then((state) => {
65+
openServiceAvailable.value = rpc.services.has('@devframes/service-open')
66+
state.on('updated', () => (openServiceAvailable.value = rpc.services.has('@devframes/service-open')))
67+
})
68+
.catch(() => {})
4369
}, { immediate: true })
4470
4571
function gcd(a: number, b: number): number {
@@ -127,6 +153,7 @@ function openInBrowser(): void {
127153
:asset="asset"
128154
detail
129155
:text-content="textContent"
156+
:highlighted-html="highlightedHtml"
130157
class="max-h-80 min-h-20 w-auto min-w-20 rounded border border-base"
131158
/>
132159
</div>
@@ -192,10 +219,10 @@ function openInBrowser(): void {
192219
<ActionButton variant="primary" icon="i-ph-download-simple-duotone" @click="openInBrowser">
193220
Download
194221
</ActionButton>
195-
<ActionButton icon="i-ph-code-duotone" :disabled="busy" @click="handleOpenInEditor">
222+
<ActionButton v-if="openServiceAvailable" icon="i-ph-code-duotone" :disabled="busy" @click="handleOpenInEditor">
196223
Open in Editor
197224
</ActionButton>
198-
<ActionButton icon="i-ph-folder-open-duotone" :disabled="busy" @click="handleRevealInFolder">
225+
<ActionButton v-if="openServiceAvailable" icon="i-ph-folder-open-duotone" :disabled="busy" @click="handleRevealInFolder">
199226
Reveal in Folder
200227
</ActionButton>
201228
<ActionButton v-if="canWrite" icon="i-ph-text-aa-duotone" @click="openRenameDialog">

plugins/assets/src/spa/app/components/AssetPreview.vue

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import FontPreview from './FontPreview.vue'
55
defineProps<{
66
asset: AssetInfo
77
textContent?: string | null
8+
/**
9+
* Server-highlighted HTML for text assets (from the `@devframes/service-shiki`
10+
* wire service); the plain `textContent` `<pre>` is the fallback.
11+
*/
12+
highlightedHtml?: string | null
813
/** Larger, interactive preview (autoplay/controls) for the details panel. */
914
detail?: boolean
1015
}>()
@@ -21,6 +26,15 @@ const BASE = 'flex items-center justify-center overflow-hidden bg-active p-1'
2126
<FontPreview :asset="asset" class="self-stretch p-2 text-2xl" />
2227
</div>
2328

29+
<!-- Shiki output is server-generated from server-read content over the
30+
trusted devframe RPC (shiki escapes the code itself), so direct
31+
injection matches the repo's handling of self-generated markup. -->
32+
<div
33+
v-else-if="asset.type === 'text' && highlightedHtml"
34+
class="asset-code-preview w-full items-start p-4"
35+
:class="BASE"
36+
v-html="highlightedHtml"
37+
/>
2438
<div v-else-if="asset.type === 'text' && textContent" class="w-full items-start p-4" :class="BASE">
2539
<pre class="max-h-40 w-full overflow-hidden text-xs font-mono">{{ textContent }}</pre>
2640
</div>
@@ -41,3 +55,24 @@ const BASE = 'flex items-center justify-center overflow-hidden bg-active p-1'
4155
<span class="i-ph-file-duotone text-3xl op-mute" />
4256
</div>
4357
</template>
58+
59+
<!-- Unscoped: `v-html` content is invisible to scoped styles. The dark rules
60+
are Shiki's standard dual-theme toggle (`--shiki-dark` variables). -->
61+
<style>
62+
.asset-code-preview pre {
63+
margin: 0;
64+
max-height: 10rem;
65+
width: 100%;
66+
overflow: hidden;
67+
font-size: 0.75rem;
68+
line-height: 1rem;
69+
background-color: transparent !important;
70+
}
71+
.dark .asset-code-preview .shiki,
72+
.dark .asset-code-preview .shiki span {
73+
color: var(--shiki-dark) !important;
74+
font-style: var(--shiki-dark-font-style) !important;
75+
font-weight: var(--shiki-dark-font-weight) !important;
76+
text-decoration: var(--shiki-dark-text-decoration) !important;
77+
}
78+
</style>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Types-only: loads the service's RPC/scope augmentations so the scoped
2+
// `call('highlight', …)` below is fully typed.
3+
import type {} from '@devframes/service-shiki'
4+
import type { DevframeRpcClient } from 'devframe/client'
5+
6+
const SHIKI_SERVICE = '@devframes/service-shiki'
7+
8+
/**
9+
* Server-highlight a text asset through the `@devframes/service-shiki` wire
10+
* service, when the host advertises it. Resolves `null` when the service is
11+
* absent or highlighting fails — the preview then falls back to a plain
12+
* `<pre>`. The language is inferred from the file extension; unknown ones
13+
* degrade to plain text server-side.
14+
*/
15+
export async function highlightAsset(rpc: DevframeRpcClient, path: string, code: string): Promise<string | null> {
16+
const shiki = rpc.services.get(SHIKI_SERVICE)
17+
if (!shiki)
18+
return null
19+
const lang = /\.([^./\\]+)$/.exec(path)?.[1]?.toLowerCase()
20+
try {
21+
const { html } = await shiki.rpc.call('highlight', { code, lang })
22+
return html
23+
}
24+
catch {
25+
return null
26+
}
27+
}

0 commit comments

Comments
 (0)