-
Notifications
You must be signed in to change notification settings - Fork 374
Copy startup terminal #5585
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Copy startup terminal #5585
Changes from all commits
864d15b
00a1975
896b8c3
9557cea
fa143b3
791ebc3
3cd1e42
94c3b94
d59619c
9e1b7b8
473b8cc
761adf1
502aa25
3ef375a
b00b18c
5d0ce42
44dcd9f
2e190dc
5816b52
99abea3
50b1570
8db7c39
ea82a52
8aa16b9
2aaf4da
33252bd
5fabe65
58af55b
c5658a8
f7bf711
c5e51e4
bd5b216
e1a020e
cb93d65
6c571fd
fb0b529
4f4ea64
403fbab
bd6147d
0948f78
6cb0fe3
c0f07b6
2c26e83
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,23 +3,77 @@ | |
<div class="p-terminal rounded-none h-full w-full p-2"> | ||
<div ref="terminalEl" class="h-full terminal-host" /> | ||
</div> | ||
<Button | ||
v-tooltip.left="{ | ||
value: tooltipText, | ||
showDelay: 300 | ||
}" | ||
icon="pi pi-copy" | ||
severity="secondary" | ||
size="small" | ||
:class=" | ||
cn('absolute top-2 right-8 transition-opacity', { | ||
'opacity-0 pointer-events-none select-none': !isHovered | ||
}) | ||
" | ||
:aria-label="tooltipText" | ||
@click="handleCopy" | ||
/> | ||
</div> | ||
</template> | ||
|
||
<script setup lang="ts"> | ||
import { useEventListener } from '@vueuse/core' | ||
import { Ref, onUnmounted, ref } from 'vue' | ||
import { useElementHover, useEventListener } from '@vueuse/core' | ||
import type { IDisposable } from '@xterm/xterm' | ||
import Button from 'primevue/button' | ||
import { Ref, computed, onMounted, onUnmounted, ref } from 'vue' | ||
import { useI18n } from 'vue-i18n' | ||
|
||
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal' | ||
import { electronAPI, isElectron } from '@/utils/envUtil' | ||
import { cn } from '@/utils/tailwindUtil' | ||
|
||
const { t } = useI18n() | ||
|
||
const emit = defineEmits<{ | ||
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement | undefined>] | ||
unmounted: [] | ||
}>() | ||
const terminalEl = ref<HTMLElement | undefined>() | ||
const rootEl = ref<HTMLElement | undefined>() | ||
emit('created', useTerminal(terminalEl), rootEl) | ||
const hasSelection = ref(false) | ||
|
||
const isHovered = useElementHover(rootEl) | ||
|
||
const terminalData = useTerminal(terminalEl) | ||
emit('created', terminalData, rootEl) | ||
|
||
const { terminal } = terminalData | ||
let selectionDisposable: IDisposable | undefined | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I usually see mutable variables like this as a red flag. Should this be a ref? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I always check Is there any actual benefit to having this be a ref? Seems like it would just add needless overhead for what this is actually used for? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One benefit would be fewer It would also let you more safely reference and reset the value in the lifecycle hooks. |
||
|
||
const tooltipText = computed(() => { | ||
return hasSelection.value | ||
? t('serverStart.copySelectionTooltip') | ||
: t('serverStart.copyAllTooltip') | ||
}) | ||
|
||
const handleCopy = async () => { | ||
const existingSelection = terminal.getSelection() | ||
const shouldSelectAll = !existingSelection | ||
if (shouldSelectAll) terminal.selectAll() | ||
|
||
const selectedText = shouldSelectAll | ||
? terminal.getSelection() | ||
: existingSelection | ||
|
||
if (selectedText) { | ||
await navigator.clipboard.writeText(selectedText) | ||
|
||
if (shouldSelectAll) { | ||
terminal.clearSelection() | ||
} | ||
} | ||
} | ||
|
||
const showContextMenu = (event: MouseEvent) => { | ||
event.preventDefault() | ||
|
@@ -30,7 +84,16 @@ if (isElectron()) { | |
useEventListener(terminalEl, 'contextmenu', showContextMenu) | ||
} | ||
|
||
onUnmounted(() => emit('unmounted')) | ||
onMounted(() => { | ||
selectionDisposable = terminal.onSelectionChange(() => { | ||
hasSelection.value = terminal.hasSelection() | ||
}) | ||
}) | ||
|
||
onUnmounted(() => { | ||
selectionDisposable?.dispose() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you change selectionDisposable to be a ref, you can also use https://vueuse.org/shared/refManualReset/ to prevent double disposals There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unless I'm missing the point, this one feels like attempting to guard against internal Vue failures. My gut reaction is that we should trust the framework to honour its lifecycle. If our framework lifecycle is broken, the app really should just stop executing. |
||
emit('unmounted') | ||
}) | ||
</script> | ||
|
||
<style scoped> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,218 @@ | ||
import { createTestingPinia } from '@pinia/testing' | ||
import { VueWrapper, mount } from '@vue/test-utils' | ||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
import { nextTick } from 'vue' | ||
import { createI18n } from 'vue-i18n' | ||
|
||
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue' | ||
|
||
// Mock xterm and related modules | ||
vi.mock('@xterm/xterm', () => ({ | ||
Terminal: vi.fn().mockImplementation(() => ({ | ||
open: vi.fn(), | ||
dispose: vi.fn(), | ||
onSelectionChange: vi.fn(() => { | ||
// Return a disposable | ||
return { | ||
dispose: vi.fn() | ||
} | ||
}), | ||
hasSelection: vi.fn(() => false), | ||
getSelection: vi.fn(() => ''), | ||
selectAll: vi.fn(), | ||
clearSelection: vi.fn(), | ||
loadAddon: vi.fn() | ||
})), | ||
IDisposable: vi.fn() | ||
})) | ||
|
||
vi.mock('@xterm/addon-fit', () => ({ | ||
FitAddon: vi.fn().mockImplementation(() => ({ | ||
fit: vi.fn(), | ||
proposeDimensions: vi.fn(() => ({ rows: 24, cols: 80 })) | ||
})) | ||
})) | ||
|
||
const mockTerminal = { | ||
open: vi.fn(), | ||
dispose: vi.fn(), | ||
onSelectionChange: vi.fn(() => ({ | ||
dispose: vi.fn() | ||
})), | ||
hasSelection: vi.fn(() => false), | ||
getSelection: vi.fn(() => ''), | ||
selectAll: vi.fn(), | ||
clearSelection: vi.fn() | ||
} | ||
|
||
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({ | ||
useTerminal: vi.fn(() => ({ | ||
terminal: mockTerminal, | ||
useAutoSize: vi.fn(() => ({ resize: vi.fn() })) | ||
})) | ||
})) | ||
|
||
vi.mock('@/utils/envUtil', () => ({ | ||
isElectron: vi.fn(() => false), | ||
electronAPI: vi.fn(() => null) | ||
})) | ||
|
||
// Mock clipboard API | ||
Object.defineProperty(navigator, 'clipboard', { | ||
value: { | ||
writeText: vi.fn().mockResolvedValue(undefined) | ||
}, | ||
configurable: true | ||
}) | ||
|
||
const i18n = createI18n({ | ||
legacy: false, | ||
locale: 'en', | ||
messages: { | ||
en: { | ||
serverStart: { | ||
copySelectionTooltip: 'Copy selection', | ||
copyAllTooltip: 'Copy all' | ||
} | ||
} | ||
} | ||
}) | ||
|
||
const mountBaseTerminal = () => { | ||
return mount(BaseTerminal, { | ||
global: { | ||
plugins: [ | ||
createTestingPinia({ | ||
createSpy: vi.fn | ||
}), | ||
i18n | ||
], | ||
stubs: { | ||
Button: { | ||
template: '<button v-bind="$attrs"><slot /></button>', | ||
props: ['icon', 'severity', 'size'] | ||
} | ||
} | ||
} | ||
}) | ||
} | ||
|
||
describe('BaseTerminal', () => { | ||
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined | ||
|
||
beforeEach(() => { | ||
vi.clearAllMocks() | ||
}) | ||
|
||
afterEach(() => { | ||
wrapper?.unmount() | ||
}) | ||
|
||
it('emits created event on mount', () => { | ||
wrapper = mountBaseTerminal() | ||
|
||
expect(wrapper.emitted('created')).toBeTruthy() | ||
expect(wrapper.emitted('created')![0]).toHaveLength(2) | ||
}) | ||
|
||
it('emits unmounted event on unmount', () => { | ||
wrapper = mountBaseTerminal() | ||
wrapper.unmount() | ||
|
||
expect(wrapper.emitted('unmounted')).toBeTruthy() | ||
}) | ||
|
||
it('button exists and has correct initial state', async () => { | ||
wrapper = mountBaseTerminal() | ||
|
||
const button = wrapper.find('button[aria-label]') | ||
expect(button.exists()).toBe(true) | ||
|
||
expect(button.classes()).toContain('opacity-0') | ||
expect(button.classes()).toContain('pointer-events-none') | ||
}) | ||
|
||
it('shows correct tooltip when no selection', async () => { | ||
mockTerminal.hasSelection.mockReturnValue(false) | ||
wrapper = mountBaseTerminal() | ||
|
||
await wrapper.trigger('mouseenter') | ||
await nextTick() | ||
|
||
const button = wrapper.find('button[aria-label]') | ||
expect(button.attributes('aria-label')).toBe('Copy all') | ||
}) | ||
|
||
it('shows correct tooltip when selection exists', async () => { | ||
mockTerminal.hasSelection.mockReturnValue(true) | ||
wrapper = mountBaseTerminal() | ||
|
||
// Trigger the selection change callback that was registered during mount | ||
expect(mockTerminal.onSelectionChange).toHaveBeenCalled() | ||
// Access the mock calls - TypeScript can't infer the mock structure dynamically | ||
const selectionCallback = (mockTerminal.onSelectionChange as any).mock | ||
.calls[0][0] | ||
selectionCallback() | ||
await nextTick() | ||
|
||
await wrapper.trigger('mouseenter') | ||
await nextTick() | ||
|
||
const button = wrapper.find('button[aria-label]') | ||
expect(button.attributes('aria-label')).toBe('Copy selection') | ||
}) | ||
|
||
it('copies selected text when selection exists', async () => { | ||
const selectedText = 'selected text' | ||
mockTerminal.hasSelection.mockReturnValue(true) | ||
mockTerminal.getSelection.mockReturnValue(selectedText) | ||
|
||
wrapper = mountBaseTerminal() | ||
|
||
await wrapper.trigger('mouseenter') | ||
await nextTick() | ||
|
||
const button = wrapper.find('button[aria-label]') | ||
await button.trigger('click') | ||
|
||
expect(mockTerminal.selectAll).not.toHaveBeenCalled() | ||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText) | ||
expect(mockTerminal.clearSelection).not.toHaveBeenCalled() | ||
}) | ||
|
||
it('copies all text when no selection exists', async () => { | ||
const allText = 'all terminal content' | ||
mockTerminal.hasSelection.mockReturnValue(false) | ||
mockTerminal.getSelection | ||
.mockReturnValueOnce('') // First call returns empty (no selection) | ||
.mockReturnValueOnce(allText) // Second call after selectAll returns all text | ||
|
||
wrapper = mountBaseTerminal() | ||
|
||
await wrapper.trigger('mouseenter') | ||
await nextTick() | ||
|
||
const button = wrapper.find('button[aria-label]') | ||
await button.trigger('click') | ||
|
||
expect(mockTerminal.selectAll).toHaveBeenCalled() | ||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText) | ||
expect(mockTerminal.clearSelection).toHaveBeenCalled() | ||
}) | ||
|
||
it('does not copy when no text available', async () => { | ||
mockTerminal.hasSelection.mockReturnValue(false) | ||
mockTerminal.getSelection.mockReturnValue('') | ||
|
||
wrapper = mountBaseTerminal() | ||
|
||
await wrapper.trigger('mouseenter') | ||
await nextTick() | ||
|
||
const button = wrapper.find('button[aria-label]') | ||
await button.trigger('click') | ||
|
||
expect(mockTerminal.selectAll).toHaveBeenCalled() | ||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled() | ||
}) | ||
}) |
Uh oh!
There was an error while loading. Please reload this page.