Skip to content

Commit e0311e6

Browse files
committed
refactor(messages): split MessagesView, move search/actions to nav, reuse @antfu/design
1 parent 602367b commit e0311e6

28 files changed

Lines changed: 654 additions & 539 deletions

alias.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ export const alias = {
101101
'@devframes/plugin-messages/client': p('messages/src/client/index.ts'),
102102
'@devframes/plugin-messages/node': p('messages/src/node/index.ts'),
103103
'@devframes/plugin-messages/constants': p('messages/src/constants.ts'),
104-
'@devframes/plugin-messages/types': p('messages/src/types.ts'),
105104
'@devframes/plugin-messages/rpc': p('messages/src/rpc/index.ts'),
106105
'@devframes/plugin-messages/cli': p('messages/src/cli.ts'),
107106
'@devframes/plugin-messages/vite': p('messages/src/vite.ts'),

plugins/messages/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
"./constants": "./dist/constants.mjs",
2828
"./node": "./dist/node/index.mjs",
2929
"./rpc": "./dist/rpc/index.mjs",
30-
"./types": "./dist/types.mjs",
3130
"./vite": "./dist/vite.mjs",
3231
"./package.json": "./package.json"
3332
},
@@ -75,6 +74,7 @@
7574
"@vueuse/core": "catalog:frontend",
7675
"colorjs.io": "catalog:frontend",
7776
"devframe": "workspace:*",
77+
"floating-vue": "catalog:frontend",
7878
"get-port-please": "catalog:deps",
7979
"h3": "catalog:deps",
8080
"storybook": "catalog:storybook",

plugins/messages/src/client/App.vue

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
2+
import type { DevframeMessageEntry } from '@devframes/hub/types'
23
import type { DevframeConnectionStatus, DevframeRpcClient } from 'devframe/client'
3-
import type { DevframeMessageEntry } from '../types'
4+
import FormSearchField from '@antfu/design/components/Form/FormSearchField.vue'
45
import LayoutToolbar from '@antfu/design/components/Layout/LayoutToolbar.vue'
56
import { computed, onBeforeUnmount, ref } from 'vue'
67
import {
@@ -13,6 +14,8 @@ import {
1314
connectionTitle,
1415
} from '../../../../design/design'
1516
import MessagesView from './components/MessagesView.vue'
17+
import MessageToolbarActions from './components/MessageToolbarActions.vue'
18+
import { useMessageFilters } from './composables/useMessageFilters'
1619
import { useMessages } from './state/messages'
1720
1821
const props = defineProps<{
@@ -21,6 +24,10 @@ const props = defineProps<{
2124
2225
const state = useMessages(props.rpc)
2326
27+
// Search / sort / filter state lives here (not in the view) so the nav bar's
28+
// search field + actions and the view's filter bar share one source of truth.
29+
const filters = useMessageFilters(() => state.entries)
30+
2431
// The live feed rides on shared-state over the socket, so a dropped socket or
2532
// refused auth is surfaced instead of silently freezing the list. The client
2633
// doesn't auto-reconnect; a reload re-runs the whole handshake.
@@ -50,9 +57,9 @@ async function onDismiss(id: string): Promise<void> {
5057
await props.rpc.call('devframes:plugin:messages:remove', id)
5158
}
5259
53-
async function onDismissMany(ids: string[]): Promise<void> {
54-
for (const id of ids)
55-
await props.rpc.call('devframes:plugin:messages:remove', id)
60+
async function onDismissFiltered(): Promise<void> {
61+
for (const entry of filters.filteredEntries)
62+
await props.rpc.call('devframes:plugin:messages:remove', entry.id)
5663
}
5764
5865
async function onClear(): Promise<void> {
@@ -85,17 +92,26 @@ async function onOpenFile(entry: DevframeMessageEntry): Promise<void> {
8592
</div>
8693

8794
<template #search>
88-
<div />
95+
<FormSearchField
96+
v-if="!connState"
97+
v-model="filters.search"
98+
size="sm"
99+
placeholder="Search messages…"
100+
class="max-w-64"
101+
/>
89102
</template>
90103

91104
<template #end>
105+
<MessageToolbarActions
106+
v-if="!connState"
107+
:filters
108+
@dismiss-filtered="onDismissFiltered"
109+
@clear="onClear"
110+
/>
92111
<span v-if="conn" :class="conn.class">
93112
<span :class="conn.dot" />
94113
{{ conn.label }}
95114
</span>
96-
<span v-if="state.entries.length > 0" class="badge-muted font-mono">
97-
{{ state.entries.length }}
98-
</span>
99115
</template>
100116
</LayoutToolbar>
101117

@@ -122,11 +138,9 @@ async function onOpenFile(entry: DevframeMessageEntry): Promise<void> {
122138
</div>
123139
<MessagesView
124140
v-else
125-
:entries="state.entries"
141+
:filters
126142
:can-open-file="canOpenFile"
127143
@dismiss="onDismiss"
128-
@dismiss-many="onDismissMany"
129-
@clear="onClear"
130144
@persist="onPersist"
131145
@open-file="onOpenFile"
132146
/>

plugins/messages/src/client/components/FilterToggles.stories.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Meta, StoryObj } from '@storybook/vue3-vite'
22
import FilterToggles from './FilterToggles.vue'
3-
import { getHashColorFromString, levels } from './MessageItemConstants'
3+
import { levels } from './message-styles'
44

55
const meta = {
66
title: 'Messages/FilterToggles',
@@ -32,11 +32,11 @@ export const LevelsFiltered: Story = {
3232
},
3333
}
3434

35-
export const HashColored: Story = {
35+
export const BadgeColored: Story = {
3636
args: {
3737
label: 'Category',
3838
items: ['a11y', 'lint', 'runtime', 'build'],
3939
active: new Set<string>(),
40-
hashColor: getHashColorFromString,
40+
badge: true,
4141
},
4242
}
Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,47 @@
11
<script setup lang="ts">
2+
import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue'
3+
24
defineProps<{
35
label: string
46
items: string[]
57
active: Set<string>
6-
/** Map item key → { icon, color, label } for styled items */
8+
/** Map item key → { icon, color, label } for styled (level/source) items. */
79
styles?: Record<string, { icon?: string, color?: string, label?: string }>
8-
/** Compute inline color via hash for items without predefined styles */
9-
hashColor?: (item: string, opacity: number) => string
10+
/** Render items as hash-colored `DisplayBadge` chips (category/label). */
11+
badge?: boolean
1012
}>()
1113
1214
defineEmits<{
1315
toggle: [item: string]
1416
}>()
17+
18+
function isDimmed(active: Set<string>, item: string): boolean {
19+
return active.size > 0 && !active.has(item)
20+
}
1521
</script>
1622

1723
<template>
18-
<span class="text-xs op40">{{ label }}</span>
24+
<span class="text-xs op-fade">{{ label }}</span>
1925
<div class="flex flex-wrap items-center gap-0.5">
2026
<button
2127
v-for="item of items"
2228
:key="item"
23-
class="px-1.5 py-0.5 rounded text-xs flex items-center gap-0.5 hover:bg-active transition"
29+
type="button"
30+
class="rounded flex items-center transition hover:bg-active"
2431
:class="[
25-
active.size === 0 || active.has(item)
26-
? (styles?.[item]?.color || '')
27-
: 'op30',
32+
badge ? 'p-0.5' : 'px-1.5 py-0.5 gap-0.5 text-xs',
33+
!badge && !isDimmed(active, item) ? (styles?.[item]?.color || '') : '',
34+
isDimmed(active, item) ? 'op30' : '',
2835
]"
29-
:style="!styles?.[item]?.color && hashColor ? {
30-
color: active.size === 0 || active.has(item) ? hashColor(item, 1) : undefined,
31-
backgroundColor: active.size === 0 || active.has(item) ? hashColor(item, 0.1) : undefined,
32-
} : undefined"
3336
@click="$emit('toggle', item)"
3437
>
35-
<div v-if="styles?.[item]?.icon" :class="styles[item]!.icon" class="w-3.5 h-3.5" />
36-
<span>{{ styles?.[item]?.label || item }}</span>
38+
<template v-if="badge">
39+
<DisplayBadge :text="item" class="text-xs" />
40+
</template>
41+
<template v-else>
42+
<div v-if="styles?.[item]?.icon" :class="styles[item]!.icon" class="w-3.5 h-3.5" />
43+
<span>{{ styles?.[item]?.label || item }}</span>
44+
</template>
3745
</button>
3846
</div>
3947
</template>

plugins/messages/src/client/components/HashBadge.vue

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<script setup lang="ts">
2+
import type { DevframeMessageEntry, DevframeMessageEntryFrom } from '@devframes/hub/types'
3+
import ActionIconButton from '@antfu/design/components/Action/ActionIconButton.vue'
4+
import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue'
5+
import FeedbackSpinner from '@antfu/design/components/Feedback/FeedbackSpinner.vue'
6+
import LayoutSeparator from '@antfu/design/components/Layout/LayoutSeparator.vue'
7+
import { useClipboard, useTimeAgo } from '@vueuse/core'
8+
import { computed } from 'vue'
9+
import { fromEntries, levels } from './message-styles'
10+
11+
const props = defineProps<{
12+
entry: DevframeMessageEntry
13+
/** Show the "open file" affordance for entries with a `filePosition`. */
14+
canOpenFile?: boolean
15+
}>()
16+
17+
const emit = defineEmits<{
18+
close: []
19+
dismiss: [id: string]
20+
openFile: [entry: DevframeMessageEntry]
21+
toggleCategory: [category: string]
22+
toggleLabel: [label: string]
23+
}>()
24+
25+
const timeAgo = useTimeAgo(computed(() => props.entry.timestamp))
26+
const { copy: copyStacktrace, copied: stacktraceCopied } = useClipboard()
27+
28+
const from = computed(() => fromEntries[props.entry.from as DevframeMessageEntryFrom])
29+
30+
function formatAbsoluteTime(ts: number): string {
31+
return new Date(ts).toLocaleTimeString()
32+
}
33+
34+
function filePositionLabel(pos: NonNullable<DevframeMessageEntry['filePosition']>): string {
35+
let path = pos.file
36+
if (pos.line)
37+
path += `:${pos.line}`
38+
if (pos.column)
39+
path += `:${pos.column}`
40+
return path
41+
}
42+
</script>
43+
44+
<template>
45+
<div class="h-full of-y-auto border-l border-base p-4">
46+
<!-- Header -->
47+
<div class="flex items-start gap-2 mb-3">
48+
<div class="flex-1 font-medium text-lg">
49+
{{ entry.message }}
50+
</div>
51+
<ActionIconButton
52+
icon="i-ph:trash-duotone"
53+
tooltip="Dismiss"
54+
label="Dismiss"
55+
@click="emit('dismiss', entry.id)"
56+
/>
57+
<ActionIconButton
58+
icon="i-ph:x"
59+
tooltip="Close detail"
60+
label="Close detail"
61+
@click="emit('close')"
62+
/>
63+
</div>
64+
65+
<!-- Metadata row -->
66+
<div class="flex flex-wrap items-center gap-2 mb-3 text-xs">
67+
<span class="flex items-center gap-1" :class="levels[entry.level].color">
68+
<div :class="levels[entry.level].icon" class="w-3.5 h-3.5" />
69+
<span class="capitalize">{{ entry.level }}</span>
70+
</span>
71+
<span v-if="from" class="flex items-center gap-1" :class="from.color">
72+
<div :class="from.icon" class="w-3.5 h-3.5" />
73+
{{ from.label }}
74+
</span>
75+
<span v-if="entry.status === 'loading'" class="flex items-center gap-1 text-amber">
76+
<FeedbackSpinner size="0.75rem" />
77+
Loading
78+
</span>
79+
<span class="op40" :title="formatAbsoluteTime(entry.timestamp)">
80+
{{ timeAgo }}
81+
</span>
82+
<span v-if="entry.notify" class="flex items-center gap-0.5 op40">
83+
<div class="i-ph:bell-duotone w-3.5 h-3.5" />
84+
notify
85+
</span>
86+
</div>
87+
88+
<!-- Description -->
89+
<div v-if="entry.description" class="text-sm op80 mb-3 whitespace-pre-wrap">
90+
{{ entry.description }}
91+
</div>
92+
93+
<!-- Category + Labels -->
94+
<div v-if="entry.category || entry.labels?.length" class="flex flex-wrap gap-1 mb-3">
95+
<DisplayBadge v-if="entry.category" :text="entry.category" as="button" class="text-xs cursor-pointer" @click="emit('toggleCategory', entry.category)" />
96+
<DisplayBadge v-for="label of entry.labels" :key="label" :text="label" as="button" class="text-xs cursor-pointer" @click="emit('toggleLabel', label)" />
97+
</div>
98+
99+
<!-- File position -->
100+
<button
101+
v-if="entry.filePosition && canOpenFile"
102+
type="button"
103+
class="flex items-start gap-1.5 text-left text-sm color-active hover:underline mb-3 break-all"
104+
@click="emit('openFile', entry)"
105+
>
106+
<div class="i-ph:file-code-duotone w-4 h-4 flex-none mt-0.5" />
107+
<span>{{ filePositionLabel(entry.filePosition) }}</span>
108+
</button>
109+
<div
110+
v-else-if="entry.filePosition"
111+
class="flex items-start gap-1.5 text-sm op60 mb-3 break-all"
112+
>
113+
<div class="i-ph:file-code-duotone w-4 h-4 flex-none mt-0.5" />
114+
<span>{{ filePositionLabel(entry.filePosition) }}</span>
115+
</div>
116+
117+
<!-- Element position -->
118+
<div v-if="entry.elementPosition" class="text-sm mb-3 bg-gray/5 rounded p-2">
119+
<div class="op50 text-xs mb-1">
120+
Element
121+
</div>
122+
<div v-if="entry.elementPosition.selector" class="font-mono text-xs">
123+
{{ entry.elementPosition.selector }}
124+
</div>
125+
<div v-if="entry.elementPosition.description" class="text-xs op70 mt-1">
126+
{{ entry.elementPosition.description }}
127+
</div>
128+
<div v-if="entry.elementPosition.boundingBox" class="text-xs op50 mt-1 font-mono">
129+
{{ entry.elementPosition.boundingBox.x }}, {{ entry.elementPosition.boundingBox.y }}
130+
({{ entry.elementPosition.boundingBox.width }} × {{ entry.elementPosition.boundingBox.height }})
131+
</div>
132+
</div>
133+
134+
<!-- Stacktrace -->
135+
<div v-if="entry.stacktrace" class="mb-3">
136+
<div class="op50 text-xs mb-1">
137+
Stack Trace
138+
</div>
139+
<div class="group relative">
140+
<pre class="text-xs bg-gray/5 rounded p-2 of-x-auto whitespace-pre-wrap font-mono">{{ entry.stacktrace }}</pre>
141+
<ActionIconButton
142+
compact
143+
class="absolute top-1.5 right-1.5 op0 group-hover:op100 text-xs bg-base border border-base"
144+
:icon="stacktraceCopied ? 'i-ph:check' : 'i-ph:copy'"
145+
tooltip="Copy"
146+
label="Copy stack trace"
147+
@click="copyStacktrace(entry.stacktrace)"
148+
/>
149+
</div>
150+
</div>
151+
152+
<!-- Timers -->
153+
<div v-if="entry.autoDismiss || entry.autoDelete" class="flex flex-wrap gap-3 mb-3 text-xs op50">
154+
<span v-if="entry.autoDismiss" class="flex items-center gap-1">
155+
<div class="i-ph:bell-slash-duotone w-3.5 h-3.5" />
156+
Auto-dismiss: {{ entry.autoDismiss / 1000 }}s
157+
</span>
158+
<span v-if="entry.autoDelete" class="flex items-center gap-1">
159+
<div class="i-ph:timer-duotone w-3.5 h-3.5" />
160+
Auto-delete: {{ entry.autoDelete / 1000 }}s
161+
</span>
162+
</div>
163+
164+
<!-- ID + Timestamp -->
165+
<LayoutSeparator />
166+
<div class="flex flex-col gap-1 text-xs op40 font-mono">
167+
<span>ID: {{ entry.id }}</span>
168+
<span>{{ formatAbsoluteTime(entry.timestamp) }} ({{ new Date(entry.timestamp).toLocaleDateString() }})</span>
169+
</div>
170+
</div>
171+
</template>

0 commit comments

Comments
 (0)