feat: save short cut image to remote by ssh
- add settings for ssh config - implement save remote function by ssh
This commit is contained in:
+55
-3
@@ -23,6 +23,7 @@ import {
|
||||
ConfirmRegion,
|
||||
CopyRegionImage,
|
||||
SaveRegionImage,
|
||||
SaveRegionToRemote,
|
||||
CancelRegion,
|
||||
} from '../wailsjs/go/main/App'
|
||||
|
||||
@@ -35,6 +36,20 @@ const hotkeyStatus = ref<HotkeyStatus>({ state: 'unknown' })
|
||||
type Mode = 'settings' | 'overlay'
|
||||
const mode = ref<Mode>('settings')
|
||||
|
||||
// `SettingsTab` is hoisted to the App shell so the sidebar (which lives
|
||||
// here) and the inner SettingsView (which renders the matching card) can
|
||||
// share a single source of truth without an event bus.
|
||||
type SettingsTab = 'general' | 's3' | 'ssh'
|
||||
const activeTab = ref<SettingsTab>('general')
|
||||
|
||||
// Sidebar entries are declarative so adding a destination type later is
|
||||
// a one-line change. The label values match the user-facing tab names.
|
||||
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 's3', label: 'S3-Conf' },
|
||||
{ id: 'ssh', label: 'SSH-Conf' },
|
||||
]
|
||||
|
||||
interface OverlayPayload {
|
||||
cssWidth: number
|
||||
cssHeight: number
|
||||
@@ -131,6 +146,28 @@ async function onOverlaySave(rect: {
|
||||
}
|
||||
}
|
||||
|
||||
async function onOverlaySaveRemote(rect: {
|
||||
rect: {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
annotations: Array<{
|
||||
tool: string
|
||||
color: string
|
||||
points: Array<{ x: number; y: number }>
|
||||
}>
|
||||
}) {
|
||||
mode.value = 'settings'
|
||||
overlayPayload.value = null
|
||||
try {
|
||||
await SaveRegionToRemote(rect as any)
|
||||
} catch {
|
||||
/* Surfaced via upload:failure */
|
||||
}
|
||||
}
|
||||
|
||||
async function onOverlayCancel() {
|
||||
mode.value = 'settings'
|
||||
overlayPayload.value = null
|
||||
@@ -191,6 +228,7 @@ onUnmounted(() => {
|
||||
@confirm="onOverlayConfirm"
|
||||
@copy="onOverlayCopy"
|
||||
@save="onOverlaySave"
|
||||
@save-remote="onOverlaySaveRemote"
|
||||
@cancel="onOverlayCancel"
|
||||
/>
|
||||
|
||||
@@ -210,10 +248,18 @@ onUnmounted(() => {
|
||||
|
||||
<main class="main">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-item active"><span class="dot" /> Settings</div>
|
||||
<div
|
||||
v-for="item in sidebarItems"
|
||||
:key="item.id"
|
||||
class="sidebar-item"
|
||||
:class="{ active: activeTab === item.id }"
|
||||
@click="activeTab = item.id"
|
||||
>
|
||||
<span class="dot" /> {{ item.label }}
|
||||
</div>
|
||||
</aside>
|
||||
<section class="content">
|
||||
<SettingsView />
|
||||
<SettingsView :tab="activeTab" />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -304,7 +350,10 @@ onUnmounted(() => {
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sidebar-item:hover:not(.active) {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.sidebar-item.active {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
@@ -341,6 +390,9 @@ onUnmounted(() => {
|
||||
.sidebar-item {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.sidebar-item:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.sidebar-item.active {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: #93c5fd;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import cancelIcon from '../assets/icons/cancel.svg?raw'
|
||||
import copyIcon from '../assets/icons/copy.svg?raw'
|
||||
import saveIcon from '../assets/icons/save-local.svg?raw'
|
||||
import saveRemoteIcon from '../assets/icons/save-remote.svg?raw'
|
||||
import uploadIcon from '../assets/icons/upload.svg?raw'
|
||||
|
||||
interface Props {
|
||||
@@ -46,6 +47,10 @@ const emit = defineEmits<{
|
||||
e: 'save',
|
||||
payload: { rect: Rect; annotations: Annotation[] }
|
||||
): void
|
||||
(
|
||||
e: 'save-remote',
|
||||
payload: { rect: Rect; annotations: Annotation[] }
|
||||
): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
@@ -108,7 +113,7 @@ const sizeLabel = computed(() => {
|
||||
|
||||
const rightToolbarPos = computed(() => {
|
||||
if (!rect.value) return null
|
||||
return placeToolbar(rect.value, 144, 40, 'right')
|
||||
return placeToolbar(rect.value, 180, 40, 'right')
|
||||
})
|
||||
|
||||
const leftToolbarPos = computed(() => {
|
||||
@@ -344,7 +349,11 @@ function onSave() {
|
||||
emitAction('save')
|
||||
}
|
||||
|
||||
function emitAction(action: 'confirm' | 'copy' | 'save') {
|
||||
function onSaveRemote() {
|
||||
emitAction('save-remote')
|
||||
}
|
||||
|
||||
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
|
||||
if (!rect.value) return
|
||||
const payload = {
|
||||
rect: { ...rect.value },
|
||||
@@ -353,6 +362,7 @@ function emitAction(action: 'confirm' | 'copy' | 'save') {
|
||||
if (action === 'confirm') emit('confirm', payload)
|
||||
if (action === 'copy') emit('copy', payload)
|
||||
if (action === 'save') emit('save', payload)
|
||||
if (action === 'save-remote') emit('save-remote', payload)
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
@@ -585,6 +595,13 @@ onUnmounted(() => {
|
||||
@click="onSave"
|
||||
v-html="saveIcon"
|
||||
/>
|
||||
<button
|
||||
class="action-btn"
|
||||
data-tip="保存远端"
|
||||
aria-label="保存远端"
|
||||
@click="onSaveRemote"
|
||||
v-html="saveRemoteIcon"
|
||||
/>
|
||||
<button
|
||||
class="action-btn primary"
|
||||
data-tip="上传云端"
|
||||
@@ -679,7 +696,7 @@ onUnmounted(() => {
|
||||
width: 190px;
|
||||
}
|
||||
.action-toolbar {
|
||||
width: 144px;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.icon-btn,
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
/*
|
||||
* SettingsView — the home screen of SnapGo before the user triggers a
|
||||
* capture. It exposes:
|
||||
* 1. Hotkey configuration
|
||||
* 2. S3-compatible OSS configuration
|
||||
* 3. Manual capture button (useful when developing or when the global
|
||||
* hotkey conflicts with another app)
|
||||
* capture. The active configuration section is selected by the App-shell
|
||||
* sidebar and passed in via the `tab` prop, so this view only owns the
|
||||
* card content for the currently selected destination type:
|
||||
*
|
||||
* • "general" — the global hotkey / capture parameters.
|
||||
* • "s3" — S3-compatible object-storage credentials.
|
||||
* • "ssh" — SSH/SCP destination for the "save remote" button.
|
||||
*
|
||||
* State flow: load() pulls config from Go on mount, save() pushes back.
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
GetConfig,
|
||||
SaveConfig,
|
||||
TestConnection,
|
||||
TestSSHConnection,
|
||||
CaptureNow,
|
||||
} from '../../wailsjs/go/main/App'
|
||||
|
||||
// Tab discriminator shared with the App shell. Kept as a string union so
|
||||
// the parent can pass the value without importing a type from this view.
|
||||
type TabId = 'general' | 's3' | 'ssh'
|
||||
|
||||
const props = defineProps<{
|
||||
tab: TabId
|
||||
}>()
|
||||
|
||||
interface S3Config {
|
||||
endpoint: string
|
||||
region: string
|
||||
@@ -28,29 +39,74 @@ interface S3Config {
|
||||
usePathStyle: boolean
|
||||
}
|
||||
|
||||
interface SSHConfig {
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password: string
|
||||
pathPrefix: string
|
||||
strictHostKey: boolean
|
||||
knownHostsPath: string
|
||||
connectTimeoutSecs: number
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
hotkey: string
|
||||
s3: S3Config
|
||||
ssh: SSHConfig
|
||||
}
|
||||
|
||||
const config = ref<AppConfig>({
|
||||
hotkey: 'cmd+shift+a',
|
||||
s3: {
|
||||
endpoint: '',
|
||||
region: 'us-east-1',
|
||||
bucket: '',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
pathPrefix: 'snapgo/',
|
||||
publicUrlBase: '',
|
||||
usePathStyle: true,
|
||||
},
|
||||
})
|
||||
// `defaultConfig` keeps the initial render in sync with the Go-side
|
||||
// DefaultAppConfig so the UI never flashes empty fields before load()
|
||||
// completes. Centralising the defaults also avoids subtle drift between
|
||||
// frontend and backend.
|
||||
function defaultConfig(): AppConfig {
|
||||
return {
|
||||
hotkey: 'cmd+shift+a',
|
||||
s3: {
|
||||
endpoint: '',
|
||||
region: 'us-east-1',
|
||||
bucket: '',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
pathPrefix: 'snapgo/',
|
||||
publicUrlBase: '',
|
||||
usePathStyle: true,
|
||||
},
|
||||
ssh: {
|
||||
host: '',
|
||||
port: 22,
|
||||
user: '',
|
||||
password: '',
|
||||
pathPrefix: 'snapgo/',
|
||||
strictHostKey: false,
|
||||
knownHostsPath: '',
|
||||
connectTimeoutSecs: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const config = ref<AppConfig>(defaultConfig())
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testingSSH = ref(false)
|
||||
const message = ref<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
||||
|
||||
// The "remote path" in the UI is shown as "~/<pathPrefix>" so the user
|
||||
// understands that everything is rooted at the remote home directory.
|
||||
// We strip leading slashes / tilde here defensively in case someone pastes
|
||||
// a full path.
|
||||
const sshPathDisplay = computed({
|
||||
get: () => config.value.ssh.pathPrefix,
|
||||
set: (raw: string) => {
|
||||
let cleaned = raw.trim()
|
||||
cleaned = cleaned.replace(/^~/, '')
|
||||
cleaned = cleaned.replace(/^\/+/, '')
|
||||
config.value.ssh.pathPrefix = cleaned
|
||||
},
|
||||
})
|
||||
|
||||
function flash(kind: 'ok' | 'err', text: string) {
|
||||
message.value = { kind, text }
|
||||
window.setTimeout(() => {
|
||||
@@ -61,7 +117,13 @@ function flash(kind: 'ok' | 'err', text: string) {
|
||||
async function load() {
|
||||
const cfg = await GetConfig()
|
||||
if (cfg) {
|
||||
config.value = cfg as unknown as AppConfig
|
||||
// Merge with defaults so a config file written before SSH support
|
||||
// existed does not leave ssh fields undefined.
|
||||
const merged = defaultConfig()
|
||||
Object.assign(merged, cfg)
|
||||
merged.s3 = { ...merged.s3, ...(cfg as any).s3 }
|
||||
merged.ssh = { ...merged.ssh, ...(cfg as any).ssh }
|
||||
config.value = merged
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +151,18 @@ async function test() {
|
||||
}
|
||||
}
|
||||
|
||||
async function testSSH() {
|
||||
testingSSH.value = true
|
||||
try {
|
||||
await TestSSHConnection(config.value.ssh as any)
|
||||
flash('ok', 'SSH connection OK')
|
||||
} catch (err: any) {
|
||||
flash('err', `SSH test failed: ${err?.message ?? err}`)
|
||||
} finally {
|
||||
testingSSH.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function manualCapture() {
|
||||
await CaptureNow()
|
||||
}
|
||||
@@ -109,7 +183,8 @@ onMounted(load)
|
||||
<button class="btn primary" @click="manualCapture">Capture now</button>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<!-- General tab — only the shortcut configuration lives here. -->
|
||||
<section v-if="props.tab === 'general'" class="card">
|
||||
<h2>Shortcut</h2>
|
||||
<label class="field">
|
||||
<span>Global hotkey</span>
|
||||
@@ -123,9 +198,15 @@ onMounted(load)
|
||||
Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift +
|
||||
a–z or 0–9. Example: <code>cmd+shift+a</code>.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<!-- S3-Conf tab — unchanged content, moved out of General. -->
|
||||
<section v-if="props.tab === 's3'" class="card">
|
||||
<h2>S3-compatible storage</h2>
|
||||
<div class="grid">
|
||||
<label class="field">
|
||||
@@ -181,6 +262,83 @@ onMounted(load)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SSH-Conf tab — destination for the save-remote action. -->
|
||||
<section v-if="props.tab === 'ssh'" class="card">
|
||||
<h2>SSH / SCP destination</h2>
|
||||
<div class="grid">
|
||||
<label class="field">
|
||||
<span>Host (IP or domain) *</span>
|
||||
<input v-model="config.ssh.host" placeholder="192.168.1.10" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Port</span>
|
||||
<input
|
||||
v-model.number="config.ssh.port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
placeholder="22"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>User *</span>
|
||||
<input v-model="config.ssh.user" placeholder="ubuntu" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password (optional)</span>
|
||||
<input v-model="config.ssh.password" type="password" />
|
||||
</label>
|
||||
<label class="field full">
|
||||
<span>Remote path (under home)</span>
|
||||
<div class="prefixed-input">
|
||||
<span class="input-prefix">~/</span>
|
||||
<input
|
||||
v-model="sshPathDisplay"
|
||||
placeholder="snapgo/"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Connect timeout (sec)</span>
|
||||
<input
|
||||
v-model.number="config.ssh.connectTimeoutSecs"
|
||||
type="number"
|
||||
min="1"
|
||||
max="120"
|
||||
placeholder="10"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Known hosts path</span>
|
||||
<input
|
||||
v-model="config.ssh.knownHostsPath"
|
||||
placeholder="~/.ssh/known_hosts"
|
||||
:disabled="!config.ssh.strictHostKey"
|
||||
/>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<input type="checkbox" v-model="config.ssh.strictHostKey" />
|
||||
<span>
|
||||
Strict host key checking (recommended for production hosts)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="!config.ssh.password" class="hint warn">
|
||||
Password is empty — please make sure password-less SSH is configured on
|
||||
this machine (e.g. via <code>ssh-copy-id</code> or your <code>ssh-agent</code>).
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" :disabled="testingSSH" @click="testSSH">
|
||||
{{ testingSSH ? 'Testing…' : 'Test connection' }}
|
||||
</button>
|
||||
<button class="btn primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="message"
|
||||
@@ -204,7 +362,7 @@ onMounted(load)
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 6px;
|
||||
@@ -224,6 +382,9 @@ kbd {
|
||||
padding: 1px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Tab strip removed — sidebar in App.vue now drives section selection. */
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
@@ -258,6 +419,7 @@ kbd {
|
||||
font-weight: 500;
|
||||
}
|
||||
.field input[type='text'],
|
||||
.field input[type='number'],
|
||||
.field input:not([type='checkbox']) {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
@@ -267,6 +429,8 @@ kbd {
|
||||
color: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field input:focus {
|
||||
border-color: #3b82f6;
|
||||
@@ -278,11 +442,48 @@ kbd {
|
||||
gap: 8px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.field input:disabled {
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.prefixed-input {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.prefixed-input:focus-within {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
|
||||
}
|
||||
.input-prefix {
|
||||
padding: 7px 10px;
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
border-right: 1px solid #d1d5db;
|
||||
}
|
||||
.prefixed-input input {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
.prefixed-input input:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.hint.warn {
|
||||
color: #b45309;
|
||||
}
|
||||
.hint code {
|
||||
background: #f3f4f6;
|
||||
padding: 1px 6px;
|
||||
@@ -359,6 +560,19 @@ kbd {
|
||||
border-color: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.field input:disabled {
|
||||
background: #1f2937;
|
||||
color: #6b7280;
|
||||
}
|
||||
.prefixed-input {
|
||||
background: #111827;
|
||||
border-color: #374151;
|
||||
}
|
||||
.input-prefix {
|
||||
background: #1f2937;
|
||||
border-right-color: #374151;
|
||||
color: #d1d5db;
|
||||
}
|
||||
.btn {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
|
||||
Reference in New Issue
Block a user