Compare commits
10 Commits
80fc5c8a0f
...
77518b3f97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77518b3f97 | ||
|
|
6dbd5ae694 | ||
|
|
e0c734d3d9 | ||
|
|
e2f600c264 | ||
|
|
f892810763 | ||
|
|
c01c005c07 | ||
|
|
c760fbdb85 | ||
|
|
51b479a8f9 | ||
|
|
c387cc2487 | ||
|
|
b0a78fc05a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,5 +13,8 @@ frontend/dist/
|
||||
.DS_Store
|
||||
*.local
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Keep frontend .gitignore for frontend-specific rules
|
||||
!frontend/.gitignore
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.SftpService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -43,6 +44,7 @@ public class SftpController {
|
||||
private final ConnectionService connectionService;
|
||||
private final UserRepository userRepository;
|
||||
private final SftpService sftpService;
|
||||
private final String uploadTempLocation;
|
||||
|
||||
private final Map<String, SftpService.SftpSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();
|
||||
@@ -53,10 +55,12 @@ public class SftpController {
|
||||
|
||||
public SftpController(ConnectionService connectionService,
|
||||
UserRepository userRepository,
|
||||
SftpService sftpService) {
|
||||
SftpService sftpService,
|
||||
@Value("${spring.servlet.multipart.location:/app/data/upload-temp}") String uploadTempLocation) {
|
||||
this.connectionService = connectionService;
|
||||
this.userRepository = userRepository;
|
||||
this.sftpService = sftpService;
|
||||
this.uploadTempLocation = uploadTempLocation;
|
||||
}
|
||||
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
@@ -290,11 +294,21 @@ public class SftpController {
|
||||
@RequestParam String path,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
Authentication authentication) {
|
||||
java.io.File tempFile = null;
|
||||
try {
|
||||
Long userId = getCurrentUserId(authentication);
|
||||
String taskId = UUID.randomUUID().toString();
|
||||
String taskKey = uploadTaskKey(userId, taskId);
|
||||
|
||||
// Save file to persistent location before async processing
|
||||
java.io.File uploadTempDir = new java.io.File(uploadTempLocation);
|
||||
if (!uploadTempDir.exists() && !uploadTempDir.mkdirs()) {
|
||||
throw new IOException("Failed to create upload temp directory: " + uploadTempDir.getAbsolutePath());
|
||||
}
|
||||
tempFile = new java.io.File(uploadTempDir, taskId + "_" + file.getOriginalFilename());
|
||||
file.transferTo(tempFile);
|
||||
final java.io.File savedFile = tempFile;
|
||||
|
||||
UploadTaskStatus status = new UploadTaskStatus(taskId, userId, connectionId,
|
||||
path, file.getOriginalFilename(), file.getSize());
|
||||
status.setController(this);
|
||||
@@ -308,11 +322,11 @@ public class SftpController {
|
||||
try {
|
||||
SftpService.SftpSession session = getOrCreateSession(connectionId, userId);
|
||||
String remotePath = (path == null || path.isEmpty() || path.equals("/"))
|
||||
? "/" + file.getOriginalFilename()
|
||||
: (path.endsWith("/") ? path + file.getOriginalFilename() : path + "/" + file.getOriginalFilename());
|
||||
? "/" + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1)
|
||||
: (path.endsWith("/") ? path + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1) : path + "/" + savedFile.getName().substring(savedFile.getName().indexOf("_") + 1));
|
||||
|
||||
AtomicLong transferred = new AtomicLong(0);
|
||||
try (java.io.InputStream in = file.getInputStream()) {
|
||||
try (java.io.InputStream in = new java.io.FileInputStream(savedFile)) {
|
||||
sftpService.upload(session, remotePath, in, new SftpService.TransferProgressListener() {
|
||||
@Override
|
||||
public void onStart(long totalBytes) {
|
||||
@@ -334,10 +348,21 @@ public class SftpController {
|
||||
existing.disconnect();
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
// Clean up temp file after upload completes
|
||||
if (savedFile.exists()) {
|
||||
savedFile.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("Async upload failed, taskId={}, connectionId={}, tempFile={}",
|
||||
taskId, connectionId, savedFile.getAbsolutePath(), e);
|
||||
status.markError(e.getMessage() != null ? e.getMessage() : "Upload failed");
|
||||
// Clean up temp file on error
|
||||
if (savedFile.exists()) {
|
||||
savedFile.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
status.setFuture(future);
|
||||
@@ -347,6 +372,11 @@ public class SftpController {
|
||||
result.put("message", "Upload started");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to prepare upload temp file, connectionId={}", connectionId, e);
|
||||
// Clean up temp file if initial save failed
|
||||
if (tempFile != null && tempFile.exists()) {
|
||||
tempFile.delete();
|
||||
}
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
|
||||
@@ -9,6 +9,8 @@ spring:
|
||||
multipart:
|
||||
max-file-size: 2048MB
|
||||
max-request-size: 2048MB
|
||||
location: ${DATA_DIR:/app/data}/upload-temp # 使用容器数据目录,避免被解析为 Tomcat 工作目录
|
||||
file-size-threshold: 0 # 立即写入磁盘,不使用内存缓冲
|
||||
datasource:
|
||||
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
|
||||
driver-class-name: org.h2.Driver
|
||||
|
||||
@@ -41,7 +41,7 @@ WORKDIR /app
|
||||
COPY --from=backend /build/target/*.jar app.jar
|
||||
|
||||
ENV DATA_DIR=/app/data
|
||||
RUN mkdir -p ${DATA_DIR}
|
||||
RUN mkdir -p ${DATA_DIR}/upload-temp
|
||||
|
||||
EXPOSE 48080
|
||||
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { ArrowLeftRight, Server, LogOut, Menu, X } from 'lucide-vue-next'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const terminalTabs = computed(() => tabsStore.tabs)
|
||||
|
||||
connectionsStore.fetchConnections().catch(() => {})
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
function handleTabClick(tabId: string) {
|
||||
tabsStore.activate(tabId)
|
||||
router.push('/terminal')
|
||||
closeSidebar()
|
||||
}
|
||||
|
||||
function handleTabClose(tabId: string, event: Event) {
|
||||
event.stopPropagation()
|
||||
tabsStore.close(tabId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -38,7 +54,7 @@ function closeSidebar() {
|
||||
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
|
||||
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
||||
</div>
|
||||
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4">
|
||||
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4 overflow-y-auto">
|
||||
<RouterLink
|
||||
to="/transfers"
|
||||
@click="closeSidebar"
|
||||
@@ -59,6 +75,32 @@ function closeSidebar() {
|
||||
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||
<span>连接列表</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- 终端标签区域 -->
|
||||
<div v-if="terminalTabs.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">
|
||||
<Terminal class="w-4 h-4" aria-hidden="true" />
|
||||
<span>终端</span>
|
||||
</div>
|
||||
<div class="space-y-1 mt-2">
|
||||
<button
|
||||
v-for="tab in terminalTabs"
|
||||
:key="tab.id"
|
||||
@click="handleTabClick(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 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset group"
|
||||
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === '/terminal' }"
|
||||
>
|
||||
<span class="truncate text-sm">{{ tab.title }}</span>
|
||||
<button
|
||||
@click="(e) => handleTabClose(tab.id, e)"
|
||||
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-slate-600 transition-all duration-200 flex-shrink-0"
|
||||
aria-label="关闭标签"
|
||||
>
|
||||
<X class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-slate-700">
|
||||
<button
|
||||
|
||||
@@ -29,6 +29,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Connections',
|
||||
component: () => import('../views/ConnectionsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'terminal',
|
||||
name: 'TerminalWorkspace',
|
||||
component: () => import('../views/TerminalWorkspaceView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'terminal/:id',
|
||||
name: 'Terminal',
|
||||
|
||||
74
frontend/src/stores/terminalTabs.ts
Normal file
74
frontend/src/stores/terminalTabs.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Connection } from '../api/connections'
|
||||
|
||||
export interface TerminalTab {
|
||||
id: string
|
||||
connectionId: number
|
||||
title: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export const useTerminalTabsStore = defineStore('terminalTabs', () => {
|
||||
const tabs = ref<TerminalTab[]>([])
|
||||
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: TerminalTab = {
|
||||
id: generateTabId(),
|
||||
connectionId: connection.id,
|
||||
title: connection.name,
|
||||
active: true,
|
||||
}
|
||||
|
||||
tabs.value.push(newTab)
|
||||
activate(newTab.id)
|
||||
return newTab.id
|
||||
}
|
||||
|
||||
function activate(tabId: string) {
|
||||
tabs.value.forEach(t => {
|
||||
t.active = t.id === tabId
|
||||
})
|
||||
activeTabId.value = tabId
|
||||
}
|
||||
|
||||
function close(tabId: string) {
|
||||
const index = tabs.value.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return
|
||||
|
||||
const wasActive = tabs.value[index]!.active
|
||||
tabs.value.splice(index, 1)
|
||||
|
||||
// 如果关闭的是活动标签,激活相邻标签
|
||||
if (wasActive && tabs.value.length > 0) {
|
||||
const newIndex = Math.min(index, tabs.value.length - 1)
|
||||
activate(tabs.value[newIndex]!.id)
|
||||
} else if (tabs.value.length === 0) {
|
||||
activeTabId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
openOrFocus,
|
||||
activate,
|
||||
close,
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
import { createRemoteTransferTask, subscribeRemoteTransferProgress, uploadFileWithProgress } from '../api/sftp'
|
||||
import { createRemoteTransferTask, subscribeRemoteTransferProgress, uploadFile, subscribeUploadProgress } from '../api/sftp'
|
||||
|
||||
export type TransferMode = 'LOCAL_TO_MANY' | 'REMOTE_TO_MANY'
|
||||
export type TransferItemStatus = 'queued' | 'running' | 'success' | 'error' | 'cancelled'
|
||||
@@ -188,17 +188,11 @@ export const useTransfersStore = defineStore('transfers', () => {
|
||||
|
||||
runs.value = [run, ...runs.value]
|
||||
|
||||
const activeXhrs: XMLHttpRequest[] = []
|
||||
let cancelled = false
|
||||
const unsubscribers: (() => void)[] = []
|
||||
controllers.set(runId, {
|
||||
abortAll: () => {
|
||||
for (const xhr of activeXhrs) {
|
||||
try {
|
||||
xhr.abort()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
cancelled = true
|
||||
},
|
||||
unsubscribers,
|
||||
})
|
||||
@@ -211,7 +205,7 @@ export const useTransfersStore = defineStore('transfers', () => {
|
||||
if (itemIndex === -1) continue
|
||||
const item = runItems[itemIndex]!
|
||||
tasks.push(async () => {
|
||||
if (item.status === 'cancelled') return
|
||||
if (item.status === 'cancelled' || cancelled) return
|
||||
item.status = 'running'
|
||||
item.progress = 0
|
||||
item.startedAt = now()
|
||||
@@ -219,28 +213,24 @@ export const useTransfersStore = defineStore('transfers', () => {
|
||||
const stopPseudoProgress = startPseudoProgress(item)
|
||||
|
||||
try {
|
||||
const xhr = uploadFileWithProgress(connectionId, targetDir || '', file)
|
||||
activeXhrs.push(xhr)
|
||||
// 发起上传并获取 taskId
|
||||
const uploadRes = await uploadFile(connectionId, targetDir || '', file)
|
||||
const taskId = uploadRes.data.taskId
|
||||
|
||||
// 订阅上传任务进度,等待真正完成
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let lastTick = 0
|
||||
xhr.onProgress = (percent) => {
|
||||
console.log('[Transfers] onProgress callback fired:', percent, 'item:', item.label)
|
||||
const t = now()
|
||||
if (t - lastTick < 100 && percent !== 100) return
|
||||
lastTick = t
|
||||
const newProgress = Math.max(item.progress || 0, Math.max(0, Math.min(100, percent)))
|
||||
console.log('[Transfers] Updating item.progress from', item.progress, 'to', newProgress)
|
||||
item.progress = newProgress
|
||||
const unsubscribe = subscribeUploadProgress(taskId, (task) => {
|
||||
const progress = Math.max(0, Math.min(100, task.progress || 0))
|
||||
item.progress = progress
|
||||
runs.value = [...runs.value]
|
||||
|
||||
if (task.status === 'success') {
|
||||
resolve()
|
||||
} else if (task.status === 'error') {
|
||||
reject(new Error(task.error || 'Upload failed'))
|
||||
}
|
||||
console.log('[Transfers] Set onProgress callback for:', item.label)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve()
|
||||
else reject(new Error(xhr.responseText || `HTTP ${xhr.status}`))
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('Network error'))
|
||||
xhr.onabort = () => reject(new Error('Cancelled'))
|
||||
})
|
||||
unsubscribers.push(unsubscribe)
|
||||
})
|
||||
|
||||
item.status = 'success'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
|
||||
const router = useRouter()
|
||||
const store = useConnectionsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
|
||||
const showForm = ref(false)
|
||||
const editingConn = ref<Connection | null>(null)
|
||||
@@ -53,7 +55,8 @@ async function handleDelete(conn: Connection) {
|
||||
}
|
||||
|
||||
function openTerminal(conn: Connection) {
|
||||
router.push(`/terminal/${conn.id}`)
|
||||
tabsStore.openOrFocus(conn)
|
||||
router.push('/terminal')
|
||||
}
|
||||
|
||||
function openSftp(conn: Connection) {
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import TerminalWidget from '../components/TerminalWidget.vue'
|
||||
import { ArrowLeft } from 'lucide-vue-next'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useConnectionsStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
|
||||
const connectionId = computed(() => Number(route.params.id))
|
||||
const conn = ref(store.getConnection(connectionId.value))
|
||||
|
||||
onMounted(() => {
|
||||
conn.value = store.getConnection(connectionId.value)
|
||||
if (!conn.value) {
|
||||
store.fetchConnections().then(() => {
|
||||
conn.value = store.getConnection(connectionId.value)
|
||||
})
|
||||
onMounted(async () => {
|
||||
// 确保连接列表已加载
|
||||
if (connectionsStore.connections.length === 0) {
|
||||
await connectionsStore.fetchConnections()
|
||||
}
|
||||
|
||||
const conn = connectionsStore.getConnection(connectionId.value)
|
||||
if (conn) {
|
||||
// 打开或聚焦该连接的标签页
|
||||
tabsStore.openOrFocus(conn)
|
||||
// 跳转到工作区
|
||||
router.replace('/terminal')
|
||||
} else {
|
||||
// 连接不存在,返回连接列表
|
||||
router.replace('/connections')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
|
||||
<button
|
||||
@click="router.push('/connections')"
|
||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
<h2 class="text-lg font-semibold text-slate-100">
|
||||
{{ conn?.name || '终端' }} - {{ conn?.username }}@{{ conn?.host }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 p-4">
|
||||
<TerminalWidget v-if="conn" :connection-id="conn.id" />
|
||||
<div v-else class="flex items-center justify-center h-64 text-slate-400">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-center text-slate-400">
|
||||
正在打开终端...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
42
frontend/src/views/TerminalWorkspaceView.vue
Normal file
42
frontend/src/views/TerminalWorkspaceView.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||
import { useConnectionsStore } from '../stores/connections'
|
||||
import TerminalWidget from '../components/TerminalWidget.vue'
|
||||
|
||||
const tabsStore = useTerminalTabsStore()
|
||||
const connectionsStore = useConnectionsStore()
|
||||
|
||||
const tabs = computed(() => tabsStore.tabs)
|
||||
|
||||
onMounted(() => {
|
||||
// 确保连接列表已加载
|
||||
if (connectionsStore.connections.length === 0) {
|
||||
connectionsStore.fetchConnections()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- 终端内容区 -->
|
||||
<div class="flex-1 min-h-0 p-4">
|
||||
<div v-if="tabs.length === 0" class="flex items-center justify-center h-full text-slate-400">
|
||||
<div class="text-center">
|
||||
<p class="text-lg mb-2">暂无打开的终端</p>
|
||||
<p class="text-sm text-slate-500">从左侧连接列表点击"终端"按钮打开</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-full">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
v-show="tab.active"
|
||||
class="h-full"
|
||||
>
|
||||
<TerminalWidget :connection-id="tab.connectionId" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "fix-transfer-and-multi-terminal",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user