Compare commits
2 Commits
f7fd41b88f
...
93cc13ddd0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93cc13ddd0 | ||
|
|
43207e24bf |
201
docs/superpowers/plans/2026-03-24-sftp-tabs.md
Normal file
201
docs/superpowers/plans/2026-03-24-sftp-tabs.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# SFTP Sidebar Tabs Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add non-duplicated, session-scoped SFTP tabs in the sidebar so users can return to file sessions without losing tab entries.
|
||||
|
||||
**Architecture:** Introduce a dedicated Pinia store (`sftpTabs`) parallel to `terminalTabs`, then wire it into `ConnectionsView`, `MainLayout`, and `SftpView`. Keep `/sftp/:id` as the single SFTP route and use route-driven synchronization so tab switching updates the view correctly without stale state.
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, Pinia, Vue Router, Vite, vue-tsc
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `frontend/src/stores/sftpTabs.ts`
|
||||
- Owns SFTP tab state and actions (`openOrFocus`, `activate`, `close`).
|
||||
- Modify: `frontend/src/views/ConnectionsView.vue`
|
||||
- Registers/focuses SFTP tab before routing to `/sftp/:id`.
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Renders SFTP sidebar tabs, handles click/close navigation behavior.
|
||||
- Modify: `frontend/src/views/SftpView.vue`
|
||||
- Keeps route param and SFTP tab state in sync via watcher.
|
||||
- Verify: `frontend/src/router/index.ts`
|
||||
- Route shape remains unchanged (`/sftp/:id`).
|
||||
- Verify: `docs/superpowers/specs/2026-03-24-sftp-tabs-design.md`
|
||||
- Source of truth for requirements and acceptance criteria.
|
||||
|
||||
### Task 1: Add Dedicated SFTP Tabs Store
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/stores/sftpTabs.ts`
|
||||
- Reference: `frontend/src/stores/terminalTabs.ts`
|
||||
|
||||
- [ ] **Step 1: Check current store pattern and workspace status**
|
||||
|
||||
Run `git status --short` and read `frontend/src/stores/terminalTabs.ts` to mirror established naming/style patterns.
|
||||
|
||||
- [ ] **Step 2: Add `SftpTab` model and store state**
|
||||
|
||||
Create `sftpTabs` store with:
|
||||
|
||||
```ts
|
||||
export interface SftpTab {
|
||||
id: string
|
||||
connectionId: number
|
||||
title: string
|
||||
active: boolean
|
||||
}
|
||||
```
|
||||
|
||||
State and computed:
|
||||
|
||||
```ts
|
||||
const tabs = ref<SftpTab[]>([])
|
||||
const activeTabId = ref<string | null>(null)
|
||||
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) || null)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement open/activate/close logic with dedup**
|
||||
|
||||
Implement actions analogous to terminal tabs:
|
||||
|
||||
- `openOrFocus(connection)` reuses existing tab by `connectionId`
|
||||
- `activate(tabId)` flips active flags and updates `activeTabId`
|
||||
- `close(tabId)` removes tab and activates neighbor when needed
|
||||
|
||||
- [ ] **Step 4: Build-check after new store**
|
||||
|
||||
Run in `frontend/`: `npm run build`
|
||||
|
||||
Expected: build succeeds and no type errors in new store.
|
||||
|
||||
### Task 2: Register SFTP Tabs from Connections Entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/ConnectionsView.vue`
|
||||
- Use: `frontend/src/stores/sftpTabs.ts`
|
||||
|
||||
- [ ] **Step 1: Add store import and instance**
|
||||
|
||||
Import `useSftpTabsStore` and initialize `const sftpTabsStore = useSftpTabsStore()`.
|
||||
|
||||
- [ ] **Step 2: Update `openSftp(conn)` behavior**
|
||||
|
||||
Before routing, call:
|
||||
|
||||
```ts
|
||||
sftpTabsStore.openOrFocus(conn)
|
||||
router.push(`/sftp/${conn.id}`)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build-check for integration safety**
|
||||
|
||||
Run in `frontend/`: `npm run build`
|
||||
|
||||
Expected: build succeeds and `ConnectionsView` typing remains valid.
|
||||
|
||||
### Task 3: Render and Control SFTP Tabs in Sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue`
|
||||
- Use: `frontend/src/stores/sftpTabs.ts`
|
||||
|
||||
- [ ] **Step 1: Add SFTP store wiring and computed values**
|
||||
|
||||
Add:
|
||||
|
||||
- `const sftpTabsStore = useSftpTabsStore()`
|
||||
- `const sftpTabs = computed(() => sftpTabsStore.tabs)`
|
||||
- route helper for SFTP context (for active styling and close-navigation logic)
|
||||
|
||||
- [ ] **Step 2: Add SFTP tab click and close handlers**
|
||||
|
||||
Implement:
|
||||
|
||||
- click handler: activate tab + `router.push(`/sftp/${tab.connectionId}`)` + close sidebar
|
||||
- close handler:
|
||||
- always close in store
|
||||
- only navigate when current route is the closed tab's route
|
||||
- if tabs remain, navigate to active tab route
|
||||
- if no tabs remain, navigate `/connections`
|
||||
|
||||
- [ ] **Step 3: Render `文件` sidebar section near terminal section**
|
||||
|
||||
Add a section matching current visual style conventions:
|
||||
|
||||
- title row with `FolderOpen` icon and text `文件`
|
||||
- list items for `sftpTabs`
|
||||
- close button per item
|
||||
- active class aligned with current route context
|
||||
|
||||
- [ ] **Step 4: Build-check and inspect no terminal regressions**
|
||||
|
||||
Run in `frontend/`: `npm run build`
|
||||
|
||||
Expected: build succeeds; no type/template errors in `MainLayout`.
|
||||
|
||||
### Task 4: Route-Driven Sync in SFTP View
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/SftpView.vue`
|
||||
- Use: `frontend/src/stores/sftpTabs.ts`
|
||||
|
||||
- [ ] **Step 1: Add SFTP tabs store and route-param watcher**
|
||||
|
||||
Add watcher on `route.params.id` with `{ immediate: true }` so first load and tab switching share one code path.
|
||||
|
||||
- [ ] **Step 2: Consolidate initialization into watcher path**
|
||||
|
||||
Use watcher flow:
|
||||
|
||||
- parse and validate id; if invalid, show user-visible error feedback, do not create/open any SFTP tab, and navigate to `/connections`
|
||||
- ensure connections loaded (fetch when needed)
|
||||
- resolve connection; if missing, show user-visible error and route back to `/connections`
|
||||
- call `sftpTabsStore.openOrFocus(connection)`
|
||||
- refresh connection-bound SFTP view state for current route id
|
||||
|
||||
Avoid duplicate request/init logic split across both watcher and old mount flow.
|
||||
|
||||
- [ ] **Step 3: Keep existing file-management behavior unchanged**
|
||||
|
||||
Do not alter existing upload/download/delete/core SFTP operations; only route/tab synchronization behavior should change.
|
||||
|
||||
- [ ] **Step 4: Build-check after route-sync changes**
|
||||
|
||||
Run in `frontend/`: `npm run build`
|
||||
|
||||
Expected: build succeeds and SftpView compiles cleanly.
|
||||
|
||||
### Task 5: End-to-End Verification
|
||||
|
||||
**Files:**
|
||||
- Verify runtime behavior in app UI
|
||||
- Verify `frontend/src/router/index.ts` unchanged for route shape
|
||||
|
||||
- [ ] **Step 1: Run final build verification**
|
||||
|
||||
Run in `frontend/`: `npm run build`
|
||||
|
||||
Expected: `vue-tsc -b` and Vite build both pass.
|
||||
|
||||
- [ ] **Step 2: Manual behavior checks**
|
||||
|
||||
Verify all acceptance criteria:
|
||||
|
||||
1. Same-connection `文件` clicks do not create duplicate tabs.
|
||||
2. Multiple SFTP tabs can be opened and switched.
|
||||
3. Navigating away (for example back to connections) keeps SFTP tabs in sidebar during current session.
|
||||
4. Closing active/non-active tabs follows designed route behavior.
|
||||
5. Switching `/sftp/:id` between tabs updates header/file list without stale previous-connection state.
|
||||
6. Invalid/nonexistent `/sftp/:id` creates no tab, shows visible feedback, and routes back to `/connections`.
|
||||
7. Terminal tabs continue to open/switch/close normally.
|
||||
|
||||
- [ ] **Step 3: Commit if user requests**
|
||||
|
||||
If the user asks to commit, keep commit focused:
|
||||
|
||||
```bash
|
||||
git add frontend/src/stores/sftpTabs.ts frontend/src/views/ConnectionsView.vue frontend/src/layouts/MainLayout.vue frontend/src/views/SftpView.vue
|
||||
git commit -m "feat: add sidebar tabs for sftp sessions"
|
||||
```
|
||||
126
docs/superpowers/specs/2026-03-24-sftp-tabs-design.md
Normal file
126
docs/superpowers/specs/2026-03-24-sftp-tabs-design.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 2026-03-24 SFTP Tabs Design
|
||||
|
||||
## Background
|
||||
|
||||
The current connection list provides both `终端` and `文件` actions. `终端` has persistent sidebar tabs, but `文件` (SFTP) opens a route directly and has no sidebar tab lifecycle. Users lose the quick return path after navigating away, which feels inconsistent.
|
||||
|
||||
## Goal
|
||||
|
||||
- Add sidebar tabs for SFTP sessions near the existing terminal tabs.
|
||||
- Ensure SFTP tabs are unique per connection (no duplicates).
|
||||
- Keep tab state only for the current in-memory session.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No persistence across full page refresh (no localStorage/sessionStorage).
|
||||
- No refactor that merges terminal and SFTP tab models into one generic tab system.
|
||||
- No route restructuring; keep `/sftp/:id` as the SFTP route entry.
|
||||
|
||||
## Current State
|
||||
|
||||
- Terminal tabs are managed by `frontend/src/stores/terminalTabs.ts` and rendered in `frontend/src/layouts/MainLayout.vue`.
|
||||
- `ConnectionsView` opens terminal via `terminalTabs.openOrFocus(conn)` and then routes to `/terminal`.
|
||||
- `ConnectionsView` opens SFTP by routing directly to `/sftp/:id`, with no tab store.
|
||||
- `SftpView` currently has no tab registration step.
|
||||
|
||||
## Selected Approach
|
||||
|
||||
Introduce a dedicated SFTP tab store and render a new sidebar tab section in `MainLayout`, following the same interaction model as terminal tabs while keeping route behavior centered on `/sftp/:id`.
|
||||
|
||||
### 1. New store: `sftpTabs`
|
||||
|
||||
Create `frontend/src/stores/sftpTabs.ts` with a model parallel to terminal tabs:
|
||||
|
||||
- `tabs: SftpTab[]`
|
||||
- `activeTabId: string | null`
|
||||
- `activeTab` computed
|
||||
- `openOrFocus(connection)`
|
||||
- `activate(tabId)`
|
||||
- `close(tabId)`
|
||||
|
||||
Each tab includes:
|
||||
|
||||
- `id: string`
|
||||
- `connectionId: number`
|
||||
- `title: string`
|
||||
- `active: boolean`
|
||||
|
||||
Dedup rule:
|
||||
|
||||
- `openOrFocus(connection)` must reuse an existing tab when `connectionId` matches.
|
||||
|
||||
### 2. Connection list behavior
|
||||
|
||||
Update `frontend/src/views/ConnectionsView.vue`:
|
||||
|
||||
- In `openSftp(conn)`, call `sftpTabs.openOrFocus(conn)` before `router.push(`/sftp/${conn.id}`)`.
|
||||
|
||||
Result:
|
||||
|
||||
- Clicking `文件` on the same connection repeatedly does not create duplicate tabs.
|
||||
|
||||
### 3. Sidebar rendering and controls
|
||||
|
||||
Update `frontend/src/layouts/MainLayout.vue`:
|
||||
|
||||
- Import and use `useSftpTabsStore`.
|
||||
- Add computed `sftpTabs`.
|
||||
- Add a new sidebar section (near terminal tabs) titled `文件`.
|
||||
- Render each tab as a clickable item with close button.
|
||||
|
||||
Interactions:
|
||||
|
||||
- Click tab: activate and navigate to `/sftp/:connectionId`.
|
||||
- Close tab:
|
||||
- Always remove the tab from store.
|
||||
- Only trigger route navigation when the current route is the closed tab's route (`/sftp/:connectionId`):
|
||||
- If another SFTP tab remains, navigate to `/sftp/:newActiveConnectionId`.
|
||||
- If no SFTP tabs remain, navigate to `/connections`.
|
||||
- If closing a non-active tab, or closing from a non-SFTP route, remove only (no route change).
|
||||
|
||||
Highlighting:
|
||||
|
||||
- Keep active style tied to both tab active state and current SFTP route context.
|
||||
|
||||
### 4. Route-entry consistency
|
||||
|
||||
Update `frontend/src/views/SftpView.vue`:
|
||||
|
||||
- Import and use `useSftpTabsStore`.
|
||||
- Watch `route.params.id` with `{ immediate: true }` so logic runs on first load and on `/sftp/:id` param changes (tab-to-tab switching).
|
||||
- On each `id` change:
|
||||
- Parse `id` to number; if invalid, navigate to `/connections`.
|
||||
- Ensure connections are loaded (fetch when needed).
|
||||
- Resolve the matching connection; if not found, show error feedback and navigate to `/connections`.
|
||||
- Call `sftpTabs.openOrFocus(connection)` and then refresh SFTP view state for that connection.
|
||||
- Consolidate route-driven initialization into this watcher (avoid a separate `onMounted` path-init flow for the same concern) so first load and param switching use one code path and do not trigger duplicate requests.
|
||||
|
||||
This keeps behavior consistent for direct navigation and sidebar tab switching, without duplicate tabs.
|
||||
|
||||
## Behavior Kept Unchanged
|
||||
|
||||
- Existing terminal tab logic and terminal workspace lifecycle.
|
||||
- Existing SFTP route path (`/sftp/:id`) and core file-management interactions remain unchanged, except for tab registration/sync and invalid-or-missing connection route handling described above.
|
||||
- Authentication and router guards.
|
||||
- UI visual language (slate/cyan styling).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `文件` opens/activates an SFTP tab in sidebar.
|
||||
- Repeatedly opening `文件` for the same connection does not create duplicate tabs.
|
||||
- SFTP tabs remain available when navigating back to connections or other pages within the same session.
|
||||
- Closing active/non-active SFTP tabs follows the navigation rules above.
|
||||
- Terminal tabs continue working exactly as before.
|
||||
- Switching between existing SFTP tabs (for example `/sftp/1` and `/sftp/2`) updates connection header and file list correctly, without stale data from the previous connection.
|
||||
- Direct navigation to an invalid or nonexistent `/sftp/:id` does not create a tab and returns the user to `/connections` with visible feedback.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run `npm run build` in `frontend/`.
|
||||
- Manual verification:
|
||||
1. Open SFTP for one connection multiple times and confirm single tab.
|
||||
2. Open SFTP for multiple connections and switch between tabs.
|
||||
3. Navigate away and return via sidebar SFTP tabs.
|
||||
4. Close active and inactive SFTP tabs and verify resulting route behavior.
|
||||
5. Re-check terminal tab open/switch/close behavior for regressions.
|
||||
6. Open an invalid/nonexistent `/sftp/:id` and verify no tab is created, visible error feedback appears, and navigation returns to `/connections`.
|
||||
@@ -3,18 +3,21 @@ import { ref, computed } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
|
||||
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal } from 'lucide-vue-next'
|
||||
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const sftpTabsStore = useSftpTabsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const terminalTabs = computed(() => tabsStore.tabs)
|
||||
const sftpTabs = computed(() => sftpTabsStore.tabs)
|
||||
const showTerminalWorkspace = computed(() => route.path === '/terminal')
|
||||
const keepTerminalWorkspaceMounted = computed(() => showTerminalWorkspace.value || terminalTabs.value.length > 0)
|
||||
|
||||
@@ -34,6 +37,35 @@ function handleTabClose(tabId: string, event: Event) {
|
||||
event.stopPropagation()
|
||||
tabsStore.close(tabId)
|
||||
}
|
||||
|
||||
function isCurrentSftpRoute(connectionId: number) {
|
||||
if (route.name !== 'Sftp') return false
|
||||
|
||||
const routeParamId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
return Number(routeParamId) === connectionId
|
||||
}
|
||||
|
||||
function handleSftpTabClick(tabId: string, connectionId: number) {
|
||||
sftpTabsStore.activate(tabId)
|
||||
router.push(`/sftp/${connectionId}`)
|
||||
closeSidebar()
|
||||
}
|
||||
|
||||
function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
|
||||
event.stopPropagation()
|
||||
|
||||
const shouldNavigate = isCurrentSftpRoute(connectionId)
|
||||
sftpTabsStore.close(tabId)
|
||||
|
||||
if (!shouldNavigate) return
|
||||
|
||||
if (sftpTabsStore.activeTab) {
|
||||
router.push(`/sftp/${sftpTabsStore.activeTab.connectionId}`)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/connections')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,6 +136,38 @@ function handleTabClose(tabId: string, event: Event) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sftpTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||
<span>文件</span>
|
||||
</div>
|
||||
<div class="space-y-1 mt-2">
|
||||
<div
|
||||
v-for="tab in sftpTabs"
|
||||
:key="tab.id"
|
||||
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 min-h-[44px] group focus-within:outline-none focus-within:ring-2 focus-within:ring-cyan-500 focus-within:ring-inset"
|
||||
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === `/sftp/${tab.connectionId}` }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleSftpTabClick(tab.id, tab.connectionId)"
|
||||
class="flex-1 min-w-0 text-left cursor-pointer focus:outline-none"
|
||||
:aria-label="`打开文件标签 ${tab.title}`"
|
||||
>
|
||||
<span class="truncate text-sm block">{{ tab.title }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="(e) => handleSftpTabClose(tab.id, tab.connectionId, e)"
|
||||
class="p-1 rounded opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100 hover:bg-slate-600 transition-opacity duration-200 transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
aria-label="关闭文件标签"
|
||||
>
|
||||
<X class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-slate-700">
|
||||
<button
|
||||
|
||||
84
frontend/src/stores/sftpTabs.ts
Normal file
84
frontend/src/stores/sftpTabs.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Connection } from '../api/connections'
|
||||
|
||||
export interface SftpTab {
|
||||
id: string
|
||||
connectionId: number
|
||||
title: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
||||
const tabs = ref<SftpTab[]>([])
|
||||
const activeTabId = ref<string | null>(null)
|
||||
|
||||
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) || null)
|
||||
|
||||
function generateTabId() {
|
||||
return `tab-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function openOrFocus(connection: Connection) {
|
||||
const existing = tabs.value.find(t => t.connectionId === connection.id)
|
||||
if (existing) {
|
||||
activate(existing.id)
|
||||
return existing.id
|
||||
}
|
||||
|
||||
const newTab: SftpTab = {
|
||||
id: generateTabId(),
|
||||
connectionId: connection.id,
|
||||
title: connection.name,
|
||||
active: true,
|
||||
}
|
||||
|
||||
tabs.value.push(newTab)
|
||||
activate(newTab.id)
|
||||
return newTab.id
|
||||
}
|
||||
|
||||
function syncActiveState() {
|
||||
const hasActiveTab = activeTabId.value !== null && tabs.value.some(t => t.id === activeTabId.value)
|
||||
if (!hasActiveTab) {
|
||||
activeTabId.value = null
|
||||
}
|
||||
|
||||
tabs.value.forEach(t => {
|
||||
t.active = t.id === activeTabId.value
|
||||
})
|
||||
}
|
||||
|
||||
function activate(tabId: string) {
|
||||
if (!tabs.value.some(t => t.id === tabId)) return
|
||||
|
||||
activeTabId.value = tabId
|
||||
syncActiveState()
|
||||
}
|
||||
|
||||
function close(tabId: string) {
|
||||
const index = tabs.value.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return
|
||||
|
||||
const wasActive = activeTabId.value === tabId
|
||||
tabs.value.splice(index, 1)
|
||||
|
||||
if (wasActive && tabs.value.length > 0) {
|
||||
const newIndex = Math.min(index, tabs.value.length - 1)
|
||||
activeTabId.value = tabs.value[newIndex]!.id
|
||||
} else if (tabs.value.length === 0) {
|
||||
activeTabId.value = null
|
||||
}
|
||||
|
||||
syncActiveState()
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
openOrFocus,
|
||||
activate,
|
||||
close,
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||
@@ -22,6 +23,7 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useConnectionsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
const sftpTabsStore = useSftpTabsStore()
|
||||
|
||||
const showForm = ref(false)
|
||||
const editingConn = ref<Connection | null>(null)
|
||||
@@ -95,6 +97,7 @@ function openTerminal(conn: Connection) {
|
||||
}
|
||||
|
||||
function openSftp(conn: Connection) {
|
||||
sftpTabsStore.openOrFocus(conn)
|
||||
router.push(`/sftp/${conn.id}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||
import * as sftpApi from '../api/sftp'
|
||||
import type { SftpFileInfo } from '../api/sftp'
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const store = useConnectionsStore()
|
||||
const sftpTabsStore = useSftpTabsStore()
|
||||
|
||||
const connectionId = computed(() => Number(route.params.id))
|
||||
const conn = ref(store.getConnection(connectionId.value))
|
||||
@@ -59,12 +61,23 @@ watch([searchQuery, showHiddenFiles, files], () => {
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
invalidateUploadContext()
|
||||
clearTimeout(searchDebounceTimer)
|
||||
stopTransferProgress()
|
||||
})
|
||||
|
||||
const showUploadProgress = ref(false)
|
||||
const uploadProgressList = ref<{ id: string; name: string; size: number; uploaded: number; total: number; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string }[]>([])
|
||||
let activeUploadContextId = 0
|
||||
|
||||
function createUploadContext() {
|
||||
activeUploadContextId += 1
|
||||
return activeUploadContextId
|
||||
}
|
||||
|
||||
function invalidateUploadContext() {
|
||||
activeUploadContextId += 1
|
||||
}
|
||||
|
||||
const totalProgress = computed(() => {
|
||||
if (uploadProgressList.value.length === 0) return 0
|
||||
@@ -164,50 +177,152 @@ async function cancelTransfer() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
conn.value = store.getConnection(connectionId.value)
|
||||
if (!conn.value) {
|
||||
store.fetchConnections().then(() => {
|
||||
conn.value = store.getConnection(connectionId.value)
|
||||
initPath()
|
||||
})
|
||||
} else {
|
||||
initPath()
|
||||
}
|
||||
})
|
||||
let routeInitRequestId = 0
|
||||
|
||||
function isStaleRouteInit(requestId?: number, isCancelled?: () => boolean) {
|
||||
return (isCancelled?.() ?? false) || (requestId != null && requestId !== routeInitRequestId)
|
||||
}
|
||||
|
||||
function resetVolatileSftpState() {
|
||||
invalidateUploadContext()
|
||||
conn.value = undefined
|
||||
currentPath.value = '.'
|
||||
pathParts.value = []
|
||||
files.value = []
|
||||
filteredFiles.value = []
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
selectedFile.value = null
|
||||
searchQuery.value = ''
|
||||
|
||||
showUploadProgress.value = false
|
||||
uploadProgressList.value = []
|
||||
uploading.value = false
|
||||
|
||||
stopTransferProgress()
|
||||
showTransferModal.value = false
|
||||
transferFile.value = null
|
||||
transferTargetConnectionId.value = null
|
||||
transferTargetPath.value = ''
|
||||
transferError.value = ''
|
||||
transferring.value = false
|
||||
resetTransferProgress()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (routeId, _, onCleanup) => {
|
||||
const requestId = ++routeInitRequestId
|
||||
let cleanedUp = false
|
||||
onCleanup(() => {
|
||||
cleanedUp = true
|
||||
})
|
||||
|
||||
const isRouteInitCancelled = () => cleanedUp
|
||||
resetVolatileSftpState()
|
||||
|
||||
const rawId = Array.isArray(routeId) ? routeId[0] : routeId
|
||||
const parsedId = Number(rawId)
|
||||
|
||||
if (!rawId || !Number.isInteger(parsedId) || parsedId <= 0) {
|
||||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||||
conn.value = undefined
|
||||
await redirectToConnections('连接参数无效,请从连接列表重新进入', requestId, isRouteInitCancelled)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.connections.length === 0) {
|
||||
try {
|
||||
await store.fetchConnections()
|
||||
} catch {
|
||||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||||
await redirectToConnections('加载连接列表失败,请稍后重试', requestId, isRouteInitCancelled)
|
||||
return
|
||||
}
|
||||
|
||||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||||
}
|
||||
|
||||
const targetConnection = store.getConnection(parsedId)
|
||||
if (!targetConnection) {
|
||||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||||
conn.value = undefined
|
||||
await redirectToConnections('连接不存在或已删除', requestId, isRouteInitCancelled)
|
||||
return
|
||||
}
|
||||
|
||||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||||
conn.value = targetConnection
|
||||
sftpTabsStore.openOrFocus(targetConnection)
|
||||
await initPath(parsedId, requestId, isRouteInitCancelled)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function redirectToConnections(
|
||||
message: string,
|
||||
requestId = routeInitRequestId,
|
||||
isCancelled?: () => boolean
|
||||
) {
|
||||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||
toast.error(message)
|
||||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||
await router.replace('/connections')
|
||||
}
|
||||
|
||||
async function initPath(
|
||||
targetConnectionId = connectionId.value,
|
||||
requestId = routeInitRequestId,
|
||||
isCancelled?: () => boolean
|
||||
) {
|
||||
try {
|
||||
const res = await sftpApi.getPwd(targetConnectionId)
|
||||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||
|
||||
function initPath() {
|
||||
sftpApi.getPwd(connectionId.value).then((res) => {
|
||||
const p = res.data.path || '/'
|
||||
currentPath.value = p === '/' ? '/' : p
|
||||
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
|
||||
loadPath()
|
||||
}).catch((err: { response?: { data?: { error?: string } } }) => {
|
||||
error.value = err?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
|
||||
await loadPath(targetConnectionId, requestId, isCancelled)
|
||||
} catch (err) {
|
||||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||
|
||||
const typedErr = err as { response?: { data?: { error?: string } } }
|
||||
error.value = typedErr?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
|
||||
currentPath.value = '.'
|
||||
pathParts.value = []
|
||||
loadPath()
|
||||
})
|
||||
await loadPath(targetConnectionId, requestId, isCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
function loadPath() {
|
||||
async function loadPath(
|
||||
targetConnectionId = connectionId.value,
|
||||
requestId = routeInitRequestId,
|
||||
isCancelled?: () => boolean
|
||||
) {
|
||||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
searchQuery.value = ''
|
||||
sftpApi
|
||||
.listFiles(connectionId.value, currentPath.value)
|
||||
.then((res) => {
|
||||
|
||||
try {
|
||||
const res = await sftpApi.listFiles(targetConnectionId, currentPath.value)
|
||||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||
|
||||
files.value = res.data.sort((a, b) => {
|
||||
if (a.directory !== b.directory) return a.directory ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
.catch((err: { response?: { data?: { error?: string } } }) => {
|
||||
error.value = err?.response?.data?.error ?? '获取文件列表失败'
|
||||
})
|
||||
.finally(() => {
|
||||
} catch (err) {
|
||||
if (isStaleRouteInit(requestId, isCancelled)) return
|
||||
|
||||
const typedErr = err as { response?: { data?: { error?: string } } }
|
||||
error.value = typedErr?.response?.data?.error ?? '获取文件列表失败'
|
||||
} finally {
|
||||
if (!isStaleRouteInit(requestId, isCancelled)) {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToDir(name: string) {
|
||||
@@ -268,9 +383,14 @@ function triggerUpload() {
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const uploadContextId = createUploadContext()
|
||||
const isUploadStale = () => uploadContextId !== activeUploadContextId
|
||||
|
||||
const input = e.target as HTMLInputElement
|
||||
const selected = input.files
|
||||
if (!selected?.length) return
|
||||
if (!selected?.length || isUploadStale()) return
|
||||
|
||||
const targetConnectionId = connectionId.value
|
||||
uploading.value = true
|
||||
error.value = ''
|
||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||
@@ -296,23 +416,34 @@ async function handleFileSelect(e: Event) {
|
||||
const MAX_PARALLEL = 5
|
||||
|
||||
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
|
||||
if (isUploadStale()) return
|
||||
|
||||
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
|
||||
const batchPromises = batch.map(async task => {
|
||||
if (!task) return
|
||||
if (!task || isUploadStale()) return
|
||||
|
||||
const { id, file } = task
|
||||
const item = uploadProgressList.value.find(item => item.id === id)
|
||||
if (!item) return
|
||||
if (!item || isUploadStale()) return
|
||||
|
||||
item.status = 'uploading'
|
||||
|
||||
try {
|
||||
if (isUploadStale()) return
|
||||
|
||||
// Start upload and get taskId
|
||||
const uploadRes = await sftpApi.uploadFile(connectionId.value, path, file)
|
||||
const uploadRes = await sftpApi.uploadFile(targetConnectionId, path, file)
|
||||
if (isUploadStale()) return
|
||||
|
||||
const taskId = uploadRes.data.taskId
|
||||
|
||||
// Poll for progress
|
||||
while (true) {
|
||||
if (isUploadStale()) return
|
||||
|
||||
const statusRes = await sftpApi.getUploadTask(taskId)
|
||||
if (isUploadStale()) return
|
||||
|
||||
const taskStatus = statusRes.data
|
||||
|
||||
item.uploaded = taskStatus.transferredBytes
|
||||
@@ -331,6 +462,8 @@ async function handleFileSelect(e: Event) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isUploadStale()) return
|
||||
|
||||
item.status = 'error'
|
||||
item.message = err?.response?.data?.error || 'Upload failed'
|
||||
}
|
||||
@@ -338,12 +471,20 @@ async function handleFileSelect(e: Event) {
|
||||
await Promise.allSettled(batchPromises)
|
||||
}
|
||||
|
||||
await loadPath()
|
||||
if (isUploadStale()) return
|
||||
|
||||
await loadPath(targetConnectionId)
|
||||
if (isUploadStale()) return
|
||||
|
||||
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
|
||||
showUploadProgress.value = false
|
||||
uploadProgressList.value = []
|
||||
uploading.value = false
|
||||
fileInputRef.value!.value = ''
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
|
||||
if (isUploadStale()) return
|
||||
toast.success(`成功上传 ${successCount} 个文件`)
|
||||
}
|
||||
|
||||
@@ -489,7 +630,7 @@ async function submitTransfer() {
|
||||
<FolderPlus class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
@click="loadPath"
|
||||
@click="loadPath()"
|
||||
:disabled="loading"
|
||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
||||
aria-label="刷新"
|
||||
|
||||
Reference in New Issue
Block a user