Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/client/standalone/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function switchEntry(id: string) {
</div>
<DockEntries
:entries="context.docks.entries"
:context="context"
class="transition duration-200 p2"
:is-vertical="false"
:selected="context.docks.selected"
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/client/webcomponents/components/Dock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ onMounted(() => {
</div>
<DockEntries
:entries="context.docks.entries"
:context="context"
class="transition duration-200 flex items-center w-full h-full justify-center px3"
:class="isMinimized ? 'opacity-0 pointer-events-none' : 'opacity-100'"
:is-vertical="context.panel.isVertical"
Expand Down
178 changes: 178 additions & 0 deletions packages/core/src/client/webcomponents/components/DockContextMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<script setup lang="ts">
import type { DevToolsDockEntry } from '@vitejs/devtools-kit'
import type { DockEntryState } from '@vitejs/devtools-kit/client'
import { onClickOutside } from '@vueuse/core'
import { computed, useTemplateRef, watch } from 'vue'

const props = defineProps<{
entry: DevToolsDockEntry | null
entryState: DockEntryState | null
position: { x: number, y: number }
}>()

const emit = defineEmits<{
(e: 'close'): void
(e: 'hide', entryId: string): void
(e: 'toggleAddressBar', entryId: string): void
}>()

const menuRef = useTemplateRef<HTMLDivElement>('menuRef')
const isVisible = computed(() => props.entry !== null)

onClickOutside(menuRef, () => {
emit('close')
})

// Close on escape key
watch(isVisible, (visible) => {
if (visible) {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
emit('close')
window.removeEventListener('keydown', handler)
}
}
window.addEventListener('keydown', handler)
}
})

// Menu position - ensure it stays within viewport
const menuStyle = computed(() => {
const { x, y } = props.position
const menuWidth = 180
const menuHeight = 150

// Adjust position to keep menu in viewport
const adjustedX = Math.min(x, window.innerWidth - menuWidth - 10)
const adjustedY = Math.min(y, window.innerHeight - menuHeight - 10)

return {
left: `${Math.max(10, adjustedX)}px`,
top: `${Math.max(10, adjustedY)}px`,
}
})

// Check if entry is an iframe type (supports refresh)
const isIframe = computed(() => props.entry?.type === 'iframe')

// Check if entry has an iframe element mounted
const hasIframe = computed(() => !!props.entryState?.domElements.iframe)

// Check if address bar is currently shown
const isAddressBarVisible = computed(() => props.entryState?.settings.showAddressBar ?? false)

function refreshIframe() {
const iframe = props.entryState?.domElements.iframe
if (iframe) {
// Refresh by reassigning src
const currentSrc = iframe.src
iframe.src = ''
// Use setTimeout to ensure the src is cleared before reassigning
setTimeout(() => {
iframe.src = currentSrc
}, 0)
}
emit('close')
}

function hideFromDock() {
if (props.entry) {
emit('hide', props.entry.id)
}
emit('close')
}

function openInNewTab() {
if (props.entry?.type === 'iframe') {
window.open(props.entry.url, '_blank')
}
emit('close')
}

function copyUrl() {
if (props.entry?.type === 'iframe') {
navigator.clipboard.writeText(props.entry.url)
}
emit('close')
}

function toggleAddressBar() {
if (props.entry) {
emit('toggleAddressBar', props.entry.id)
}
emit('close')
}
</script>

<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="isVisible"
ref="menuRef"
class="fixed z-[10000] min-w-44 py-1 rounded-lg bg-[#1a1a1a] border border-[#333] shadow-2xl"
:style="menuStyle"
>
<!-- Header with entry title -->
<div class="px-3 py-1.5 text-xs text-[#888] border-b border-[#333] truncate">
{{ entry?.title }}
</div>

<!-- Iframe-specific actions -->
<template v-if="isIframe">
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 hover:bg-[#333] transition-colors text-left text-[#ccc]"
:class="!hasIframe ? 'opacity-50 cursor-not-allowed' : ''"
:disabled="!hasIframe"
@click="refreshIframe"
>
<div class="i-ph:arrow-clockwise w-4 h-4 flex-none op70" />
<span class="text-sm">Refresh</span>
</button>

<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 hover:bg-[#333] transition-colors text-left text-[#ccc]"
@click="openInNewTab"
>
<div class="i-ph:arrow-square-out w-4 h-4 flex-none op70" />
<span class="text-sm">Open in new tab</span>
</button>

<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 hover:bg-[#333] transition-colors text-left text-[#ccc]"
@click="copyUrl"
>
<div class="i-ph:copy w-4 h-4 flex-none op70" />
<span class="text-sm">Copy URL</span>
</button>

<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 hover:bg-[#333] transition-colors text-left text-[#ccc]"
@click="toggleAddressBar"
>
<div class="w-4 h-4 flex-none op70" :class="isAddressBarVisible ? 'i-ph:eye-slash' : 'i-ph:address-book'" />
<span class="text-sm">{{ isAddressBarVisible ? 'Hide' : 'Show' }} address bar</span>
</button>

<div class="my-1 border-t border-[#333]" />
</template>

<!-- Common actions -->
<button
class="w-full flex items-center gap-2.5 px-3 py-1.5 hover:bg-[#333] transition-colors text-left text-red-400"
@click="hideFromDock"
>
<div class="i-ph:eye-slash w-4 h-4 flex-none op70" />
<span class="text-sm">Hide from dock</span>
</button>
</div>
</Transition>
</Teleport>
</template>
161 changes: 148 additions & 13 deletions packages/core/src/client/webcomponents/components/DockEntries.vue
Original file line number Diff line number Diff line change
@@ -1,39 +1,174 @@
<script setup lang="ts">
import type { DevToolsDockEntry } from '@vitejs/devtools-kit'
import { toRefs } from 'vue'
import type { DevToolsDockEntry, DevToolsDockEntryBase, DevToolsDockEntryCategory } from '@vitejs/devtools-kit'
import type { DocksContext } from '@vitejs/devtools-kit/client'
import { computed, reactive, toRefs } from 'vue'
import DockContextMenu from './DockContextMenu.vue'
import DockEntry from './DockEntry.vue'
import DockOverflowMenu from './DockOverflowMenu.vue'

const props = defineProps<{
const props = withDefaults(defineProps<{
entries: DevToolsDockEntry[]
selected: DevToolsDockEntry | null
isVertical: boolean
}>()
context: DocksContext
/**
* Maximum number of visible dock entries before showing overflow menu
* Set to 0 or negative to disable overflow (show all)
*/
maxVisible?: number
}>(), {
maxVisible: 8,
})

const emit = defineEmits<{
(e: 'select', entry: DevToolsDockEntry): void
}>()

const { selected, isVertical, entries } = toRefs(props)
const { selected, isVertical, entries, maxVisible, context } = toRefs(props)

// Category order for sorting groups
const categoryOrder: DevToolsDockEntryCategory[] = ['app', 'framework', 'web', 'default', 'advanced']

// Filter visible entries
const visibleEntries = computed(() => entries.value.filter(e => !e.isHidden))

// Split entries into visible and overflow
const splitEntries = computed(() => {
const all = visibleEntries.value
const max = maxVisible.value

// If maxVisible is 0 or negative, show all entries
if (max <= 0) {
return { mainEntries: all, overflowEntries: [] as DevToolsDockEntry[] }
}

// Reserve 1 spot for overflow button if needed
const hasOverflow = all.length > max
const visibleCount = hasOverflow ? max - 1 : all.length

return {
mainEntries: all.slice(0, visibleCount),
overflowEntries: all.slice(visibleCount),
}
})

const mainEntries = computed(() => splitEntries.value.mainEntries)
const overflowEntries = computed(() => splitEntries.value.overflowEntries)

// Group main entries by category
const groupedEntries = computed(() => {
const groups = new Map<DevToolsDockEntryCategory, DevToolsDockEntry[]>()

for (const entry of mainEntries.value) {
const category = entry.category ?? 'default'
if (!groups.has(category)) {
groups.set(category, [])
}
groups.get(category)!.push(entry)
}

// Sort groups by category order and filter out empty groups
return categoryOrder
.filter(cat => groups.has(cat))
.map(cat => ({
category: cat,
entries: groups.get(cat)!,
}))
})

function toggleDockEntry(dock: DevToolsDockEntry) {
if (selected.value?.id === dock.id)
emit('select', undefined!)
else
emit('select', dock)
}

// Context menu state
const contextMenu = reactive<{
entry: DevToolsDockEntry | null
position: { x: number, y: number }
}>({
entry: null,
position: { x: 0, y: 0 },
})

const contextMenuEntryState = computed(() => {
if (!contextMenu.entry)
return null
return context.value.docks.getStateById(contextMenu.entry.id) ?? null
})

function onContextMenu(event: { entry: DevToolsDockEntryBase, position: { x: number, y: number } }) {
// Find the full entry from entries
const fullEntry = entries.value.find(e => e.id === event.entry.id)
if (fullEntry) {
contextMenu.entry = fullEntry
contextMenu.position = event.position
}
}

function closeContextMenu() {
contextMenu.entry = null
}

function hideEntry(entryId: string) {
// TODO: Implement persistent hidden docks storage
// For now, we'll just log it - actual hiding would need RPC call to server
console.warn(`[Vite DevTools] Hide dock "${entryId}" - not yet implemented (needs server-side storage)`)
}

function toggleAddressBar(entryId: string) {
const entryState = context.value.docks.getStateById(entryId)
if (entryState) {
entryState.settings.showAddressBar = !entryState.settings.showAddressBar
}
}
</script>

<template>
<div>
<template v-for="dock of entries" :key="dock.id">
<DockEntry
v-if="!dock.isHidden"
:dock
:is-selected="selected?.id === dock.id"
:is-dimmed="selected ? (selected.id !== dock.id) : false"
<div class="flex items-center gap-1">
<template v-for="(group, groupIndex) of groupedEntries" :key="group.category">
<!-- Category separator (not for first group) -->
<div
v-if="groupIndex > 0"
class="vite-devtools-dock-separator mx-0.5 bg-current op20 rounded-full"
:class="isVertical ? 'w-3 h-0.5' : 'w-0.5 h-3'"
/>
<!-- Entries in this category -->
<template v-for="dock of group.entries" :key="dock.id">
<DockEntry
:dock
:is-selected="selected?.id === dock.id"
:is-dimmed="selected ? (selected.id !== dock.id) : false"
:is-vertical="isVertical"
@click="toggleDockEntry(dock)"
@contextmenu="onContextMenu"
/>
</template>
</template>

<!-- Overflow menu -->
<template v-if="overflowEntries.length > 0">
<div
class="vite-devtools-dock-separator mx-0.5 bg-current op20 rounded-full"
:class="isVertical ? 'w-3 h-0.5' : 'w-0.5 h-3'"
/>
<DockOverflowMenu
:entries="overflowEntries"
:selected="selected"
:is-vertical="isVertical"
@click="toggleDockEntry(dock)"
@select="toggleDockEntry"
/>
</template>

<!-- Context Menu -->
<DockContextMenu
:entry="contextMenu.entry"
:entry-state="contextMenuEntryState"
:position="contextMenu.position"
@close="closeContextMenu"
@hide="hideEntry"
@toggle-address-bar="toggleAddressBar"
/>
</div>
</template>
Loading