Skip to content
Merged
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 desktop/frontend/src/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export const en = {
back: "Back",
controlMode: "Control mode",
pasteClipboard: "Paste",
pasteImage: "Image",
pasteConfirm: "Paste",
pastePreview: "Clipboard text",
viewOnly: "View-only session. Controls are disabled.",
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ export const zhCN = {
back: "返回",
controlMode: "控制模式",
pasteClipboard: "粘贴",
pasteImage: "图片",
pasteConfirm: "确认粘贴",
pastePreview: "剪贴板文本",
viewOnly: "只读会话,控制按钮已禁用。",
Expand Down
31 changes: 31 additions & 0 deletions desktop/frontend/src/mobile/MobileTerminal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const emit = defineEmits<{ (e: 'ended'): void; (e: 'tokenInvalid'): void; (e: 'm
const { t } = useI18n()

const container = ref<HTMLDivElement | null>(null)
const imageInput = ref<HTMLInputElement | null>(null)
const isDriver = ref(true)
const controlMode = ref(false)
const pasteOpen = ref(false)
Expand Down Expand Up @@ -78,6 +79,26 @@ function confirmPaste() {
pasteOpen.value = false
}

function openImagePicker() {
if (!canSend.value) return
imageInput.value?.click()
}

async function onImagePicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
// Reset the input synchronously so picking the same file twice still fires
// a 'change' event next time.
input.value = ''
if (!file || !canSend.value) return
try {
await conn?.sendPasteImage(file, file.name || 'mobile-image')
} catch {
// sendPasteImage already routes status='error' to MobileApp via onStatus;
// a separate toast here would be redundant.
}
}

onMounted(() => {
term = new Terminal({ fontSize: 12, convertEol: false, cursorBlink: true })
fit = new FitAddon()
Expand Down Expand Up @@ -182,6 +203,15 @@ onBeforeUnmount(() => {
@click="sendAux(k.seq)"
>{{ k.label }}</button>
<button class="key paste" data-testid="mobile-paste" :disabled="!canSend" @click="openPasteConfirm">{{ t('mobile.pasteClipboard') }}</button>
<button class="key paste" data-testid="mobile-image" :disabled="!canSend" @click="openImagePicker">{{ t('mobile.pasteImage') }}</button>
<input
ref="imageInput"
data-testid="mobile-image-input"
type="file"
accept="image/*"
class="hidden-file"
@change="onImagePicked"
/>
</div>
<div class="quickbar">
<button
Expand Down Expand Up @@ -240,4 +270,5 @@ onBeforeUnmount(() => {
.paste-confirm { display: grid; grid-template-columns: 1fr auto auto; gap: 6px; align-items: center; }
.paste-confirm textarea { min-width: 0; resize: vertical; border-radius: 8px; border: 1px solid #1e2638; background: #020617; color: #e2e8f0; padding: 6px 8px; font: 0.78rem ui-monospace, Menlo, monospace; }
.paste-confirm button { height: 30px; border-radius: 7px; border: 1px solid #1e2638; background: #11182b; color: #cbd5e1; padding: 0 10px; }
.hidden-file { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; left: -9999px; }
</style>
41 changes: 41 additions & 0 deletions desktop/frontend/src/mobile/__tests__/MobileTerminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const detach = vi.fn()
const sendInput = vi.fn()
const sendResize = vi.fn()
const claimDriver = vi.fn()
const sendPasteImage = vi.fn().mockResolvedValue(true)
let lastHandlers: any = null
let lastArgs: any = null

Expand All @@ -20,7 +21,9 @@ vi.mock('../../lib/connection', () => ({
sendInput(s: string) { sendInput(s) }
sendResize(c: number, r: number) { sendResize(c, r) }
claimDriver() { claimDriver() }
sendPasteImage(blob: Blob, filename: string) { return sendPasteImage(blob, filename) }
},
pasteImageBlockReason: vi.fn().mockReturnValue(null),
}))

const termWrite = vi.fn()
Expand Down Expand Up @@ -131,6 +134,44 @@ describe('MobileTerminal', () => {
expect(w.find('.term').classes()).not.toContain('inert')
})

it('image button delegates the chosen file to sendPasteImage when controlMode is on', async () => {
const w = mount(MobileTerminal, { props: { endpoint: { url: 'wss://r', token: 'atk_t' }, sessionId: 's1', info, active: true } })
// canSend gate: must be driver (default) + controlMode on. Toggle it on.
await w.find('[data-testid="mobile-control-toggle"]').setValue(true)

const btn = w.find('[data-testid="mobile-image"]')
expect(btn.exists()).toBe(true)
expect(btn.attributes('disabled')).toBeUndefined()

const fileInput = w.find('[data-testid="mobile-image-input"]')
expect(fileInput.exists()).toBe(true)
expect(fileInput.attributes('accept')).toContain('image/')

// Simulate the native picker returning a PNG.
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'snap.png', { type: 'image/png' })
const inputEl = fileInput.element as HTMLInputElement
Object.defineProperty(inputEl, 'files', { value: [file], configurable: true })
await fileInput.trigger('change')

expect(sendPasteImage).toHaveBeenCalledWith(file, 'snap.png')
})

it('image button is disabled when not in control mode', () => {
const w = mount(MobileTerminal, { props: { endpoint: { url: 'wss://r', token: 'atk_t' }, sessionId: 's1', info, active: true } })
// controlMode is false by default — image button must be disabled.
expect(w.find('[data-testid="mobile-image"]').attributes('disabled')).toBe('')
})

it('image button is not rendered for view-only sessions', () => {
const viewOnly = { ...info, remote_permission: 'view' }
const w = mount(MobileTerminal, { props: { endpoint: { url: 'wss://r', token: 'atk_t' }, sessionId: 's1', info: viewOnly, active: true } })
// view-only mirrors mobile-paste (also hidden / disabled). Mirror that behaviour for image.
const btn = w.find('[data-testid="mobile-image"]')
if (btn.exists()) {
expect(btn.attributes('disabled')).toBe('')
}
})

it('renders a mobile control panel with required keys and quick text buttons', () => {
const w = mount(MobileTerminal, { props: { endpoint: { url: 'wss://r', token: 'atk_t' }, sessionId: 's1', info, active: true } })
expect(w.find('[data-testid="mobile-control-panel"]').exists()).toBe(true)
Expand Down
Loading