From f606d200002d41cea81d27c5ff66e73172b83b86 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Fri, 10 Apr 2026 11:04:21 +0800 Subject: [PATCH] feat: unify moba workspace and persist session tree layout --- .../SessionTreeLayoutController.java | 45 +++ .../sshmanager/dto/SessionTreeLayoutDto.java | 16 + .../sshmanager/dto/SessionTreeNodeDto.java | 21 ++ .../sshmanager/entity/SessionTreeLayout.java | 34 ++ .../SessionTreeLayoutRepository.java | 11 + .../sshmanager/service/ConnectionService.java | 20 +- .../service/SessionTreeLayoutService.java | 69 ++++ .../SessionTreeLayoutControllerTest.java | 70 ++++ .../service/SessionTreeLayoutServiceTest.java | 91 +++++ frontend/src/api/sessionTree.ts | 27 ++ frontend/src/components/ContextMenu.vue | 20 +- frontend/src/components/SessionTree.vue | 333 +++++++++++++++--- frontend/src/components/SessionTreeNode.vue | 2 +- frontend/src/components/TopToolbar.vue | 142 +++++++- frontend/src/components/WorkspacePanel.vue | 69 ++-- frontend/src/composables/useConnectionSync.ts | 3 + frontend/src/composables/useTreeSearch.ts | 15 +- frontend/src/layouts/MainLayout.vue | 247 ++----------- frontend/src/layouts/MobaLayout.vue | 164 ++++++++- frontend/src/router/index.ts | 88 ++--- frontend/src/stores/connections.ts | 29 +- frontend/src/stores/sessionTree.ts | 119 ++++++- frontend/src/stores/workspace.ts | 133 ++++++- frontend/src/types/sessionTree.ts | 3 +- frontend/src/types/workspace.ts | 5 + frontend/src/views/LoginView.vue | 4 +- frontend/src/views/TransfersView.vue | 29 +- 27 files changed, 1383 insertions(+), 426 deletions(-) create mode 100644 backend/src/main/java/com/sshmanager/controller/SessionTreeLayoutController.java create mode 100644 backend/src/main/java/com/sshmanager/dto/SessionTreeLayoutDto.java create mode 100644 backend/src/main/java/com/sshmanager/dto/SessionTreeNodeDto.java create mode 100644 backend/src/main/java/com/sshmanager/entity/SessionTreeLayout.java create mode 100644 backend/src/main/java/com/sshmanager/repository/SessionTreeLayoutRepository.java create mode 100644 backend/src/main/java/com/sshmanager/service/SessionTreeLayoutService.java create mode 100644 backend/src/test/java/com/sshmanager/controller/SessionTreeLayoutControllerTest.java create mode 100644 backend/src/test/java/com/sshmanager/service/SessionTreeLayoutServiceTest.java create mode 100644 frontend/src/api/sessionTree.ts diff --git a/backend/src/main/java/com/sshmanager/controller/SessionTreeLayoutController.java b/backend/src/main/java/com/sshmanager/controller/SessionTreeLayoutController.java new file mode 100644 index 0000000..04da273 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/controller/SessionTreeLayoutController.java @@ -0,0 +1,45 @@ +package com.sshmanager.controller; + +import com.sshmanager.dto.SessionTreeLayoutDto; +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.service.SessionTreeLayoutService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/session-tree") +public class SessionTreeLayoutController { + + private final SessionTreeLayoutService sessionTreeLayoutService; + private final UserRepository userRepository; + + public SessionTreeLayoutController(SessionTreeLayoutService sessionTreeLayoutService, + UserRepository userRepository) { + this.sessionTreeLayoutService = sessionTreeLayoutService; + this.userRepository = userRepository; + } + + @GetMapping + public ResponseEntity getLayout(Authentication authentication) { + Long userId = getCurrentUserId(authentication); + return ResponseEntity.ok(sessionTreeLayoutService.getLayout(userId)); + } + + @PutMapping + public ResponseEntity saveLayout(@RequestBody SessionTreeLayoutDto request, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + return ResponseEntity.ok(sessionTreeLayoutService.saveLayout(userId, request)); + } + + private Long getCurrentUserId(Authentication auth) { + User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found")); + return user.getId(); + } +} diff --git a/backend/src/main/java/com/sshmanager/dto/SessionTreeLayoutDto.java b/backend/src/main/java/com/sshmanager/dto/SessionTreeLayoutDto.java new file mode 100644 index 0000000..58a89fe --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/SessionTreeLayoutDto.java @@ -0,0 +1,16 @@ +package com.sshmanager.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SessionTreeLayoutDto { + + private List nodes = new ArrayList(); +} diff --git a/backend/src/main/java/com/sshmanager/dto/SessionTreeNodeDto.java b/backend/src/main/java/com/sshmanager/dto/SessionTreeNodeDto.java new file mode 100644 index 0000000..8fe2ee1 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/SessionTreeNodeDto.java @@ -0,0 +1,21 @@ +package com.sshmanager.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SessionTreeNodeDto { + + private String id; + private String type; + private String name; + private String parentId; + private Integer order; + private Long connectionId; + private Boolean expanded; + private Long createdAt; + private Long updatedAt; +} diff --git a/backend/src/main/java/com/sshmanager/entity/SessionTreeLayout.java b/backend/src/main/java/com/sshmanager/entity/SessionTreeLayout.java new file mode 100644 index 0000000..d7b3a4d --- /dev/null +++ b/backend/src/main/java/com/sshmanager/entity/SessionTreeLayout.java @@ -0,0 +1,34 @@ +package com.sshmanager.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import java.time.Instant; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "session_tree_layouts") +public class SessionTreeLayout { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private Long userId; + + @Column(nullable = false, columnDefinition = "CLOB") + private String layoutJson; + + @Column(nullable = false) + private Instant updatedAt = Instant.now(); +} diff --git a/backend/src/main/java/com/sshmanager/repository/SessionTreeLayoutRepository.java b/backend/src/main/java/com/sshmanager/repository/SessionTreeLayoutRepository.java new file mode 100644 index 0000000..405609c --- /dev/null +++ b/backend/src/main/java/com/sshmanager/repository/SessionTreeLayoutRepository.java @@ -0,0 +1,11 @@ +package com.sshmanager.repository; + +import com.sshmanager.entity.SessionTreeLayout; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SessionTreeLayoutRepository extends JpaRepository { + + Optional findByUserId(Long userId); +} diff --git a/backend/src/main/java/com/sshmanager/service/ConnectionService.java b/backend/src/main/java/com/sshmanager/service/ConnectionService.java index 5827e8e..a7b8df4 100644 --- a/backend/src/main/java/com/sshmanager/service/ConnectionService.java +++ b/backend/src/main/java/com/sshmanager/service/ConnectionService.java @@ -100,15 +100,17 @@ public class ConnectionService { return ConnectionDto.fromEntity(conn); } - @Transactional - public void delete(Long id, Long userId) { - Connection conn = connectionRepository.findById(id).orElseThrow( - () -> new RuntimeException("Connection not found: " + id)); - if (!conn.getUserId().equals(userId)) { - throw new RuntimeException("Access denied"); - } - connectionRepository.delete(conn); - } + @Transactional + public void delete(Long id, Long userId) { + Connection conn = connectionRepository.findById(id).orElse(null); + if (conn == null) { + return; + } + if (!conn.getUserId().equals(userId)) { + throw new RuntimeException("Access denied"); + } + connectionRepository.delete(conn); + } public Connection getConnectionForSsh(Long id, Long userId) { Connection conn = connectionRepository.findById(id).orElseThrow( diff --git a/backend/src/main/java/com/sshmanager/service/SessionTreeLayoutService.java b/backend/src/main/java/com/sshmanager/service/SessionTreeLayoutService.java new file mode 100644 index 0000000..44aa16b --- /dev/null +++ b/backend/src/main/java/com/sshmanager/service/SessionTreeLayoutService.java @@ -0,0 +1,69 @@ +package com.sshmanager.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sshmanager.dto.SessionTreeLayoutDto; +import com.sshmanager.entity.SessionTreeLayout; +import com.sshmanager.repository.SessionTreeLayoutRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; + +@Service +public class SessionTreeLayoutService { + + private final SessionTreeLayoutRepository sessionTreeLayoutRepository; + private final ObjectMapper objectMapper; + + public SessionTreeLayoutService(SessionTreeLayoutRepository sessionTreeLayoutRepository, + ObjectMapper objectMapper) { + this.sessionTreeLayoutRepository = sessionTreeLayoutRepository; + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public SessionTreeLayoutDto getLayout(Long userId) { + SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElse(null); + if (layout == null || layout.getLayoutJson() == null || layout.getLayoutJson().trim().isEmpty()) { + return createEmptyLayout(); + } + + try { + SessionTreeLayoutDto parsed = objectMapper.readValue(layout.getLayoutJson(), SessionTreeLayoutDto.class); + if (parsed.getNodes() == null) { + parsed.setNodes(new ArrayList<>()); + } + return parsed; + } catch (Exception e) { + return createEmptyLayout(); + } + } + + @Transactional + public SessionTreeLayoutDto saveLayout(Long userId, SessionTreeLayoutDto request) { + SessionTreeLayoutDto payload = request == null ? createEmptyLayout() : request; + if (payload.getNodes() == null) { + payload.setNodes(new ArrayList<>()); + } + + final String layoutJson; + try { + layoutJson = objectMapper.writeValueAsString(payload); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize session tree layout", e); + } + + SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElseGet(SessionTreeLayout::new); + layout.setUserId(userId); + layout.setLayoutJson(layoutJson); + layout.setUpdatedAt(Instant.now()); + sessionTreeLayoutRepository.save(layout); + + return payload; + } + + private SessionTreeLayoutDto createEmptyLayout() { + return new SessionTreeLayoutDto(new ArrayList<>()); + } +} diff --git a/backend/src/test/java/com/sshmanager/controller/SessionTreeLayoutControllerTest.java b/backend/src/test/java/com/sshmanager/controller/SessionTreeLayoutControllerTest.java new file mode 100644 index 0000000..d5d6218 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/controller/SessionTreeLayoutControllerTest.java @@ -0,0 +1,70 @@ +package com.sshmanager.controller; + +import com.sshmanager.dto.SessionTreeLayoutDto; +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.service.SessionTreeLayoutService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SessionTreeLayoutControllerTest { + + @Mock + private SessionTreeLayoutService sessionTreeLayoutService; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private SessionTreeLayoutController sessionTreeLayoutController; + + private Authentication authentication; + + @BeforeEach + void setUp() { + authentication = mock(Authentication.class); + when(authentication.getName()).thenReturn("testuser"); + + User user = new User(); + user.setId(1L); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user)); + } + + @Test + void getLayoutUsesCurrentUserId() { + SessionTreeLayoutDto expected = new SessionTreeLayoutDto(); + when(sessionTreeLayoutService.getLayout(1L)).thenReturn(expected); + + ResponseEntity response = sessionTreeLayoutController.getLayout(authentication); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(expected, response.getBody()); + verify(sessionTreeLayoutService).getLayout(1L); + } + + @Test + void saveLayoutUsesCurrentUserId() { + SessionTreeLayoutDto request = new SessionTreeLayoutDto(); + when(sessionTreeLayoutService.saveLayout(1L, request)).thenReturn(request); + + ResponseEntity response = sessionTreeLayoutController.saveLayout(request, authentication); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(request, response.getBody()); + verify(sessionTreeLayoutService).saveLayout(1L, request); + } +} diff --git a/backend/src/test/java/com/sshmanager/service/SessionTreeLayoutServiceTest.java b/backend/src/test/java/com/sshmanager/service/SessionTreeLayoutServiceTest.java new file mode 100644 index 0000000..f5ac3a5 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/service/SessionTreeLayoutServiceTest.java @@ -0,0 +1,91 @@ +package com.sshmanager.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sshmanager.dto.SessionTreeLayoutDto; +import com.sshmanager.dto.SessionTreeNodeDto; +import com.sshmanager.entity.SessionTreeLayout; +import com.sshmanager.repository.SessionTreeLayoutRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SessionTreeLayoutServiceTest { + + @Mock + private SessionTreeLayoutRepository sessionTreeLayoutRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private SessionTreeLayoutService sessionTreeLayoutService; + + @Test + void getLayoutReturnsEmptyWhenNoSavedData() { + when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty()); + + SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L); + + assertTrue(result.getNodes().isEmpty()); + } + + @Test + void getLayoutParsesSavedJson() throws Exception { + SessionTreeLayout saved = new SessionTreeLayout(); + saved.setUserId(1L); + saved.setLayoutJson("{\"nodes\":[{\"id\":\"n1\"}]}"); + + SessionTreeLayoutDto parsed = new SessionTreeLayoutDto(Arrays.asList( + new SessionTreeNodeDto("n1", "folder", "我的连接", null, 0, null, true, 1L, 1L) + )); + + when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.of(saved)); + when(objectMapper.readValue(saved.getLayoutJson(), SessionTreeLayoutDto.class)).thenReturn(parsed); + + SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L); + + assertEquals(1, result.getNodes().size()); + assertEquals("n1", result.getNodes().get(0).getId()); + } + + @Test + void saveLayoutNormalizesNullNodes() throws Exception { + SessionTreeLayoutDto request = new SessionTreeLayoutDto(); + request.setNodes(null); + + when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty()); + when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}"); + + SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, request); + + assertTrue(result.getNodes().isEmpty()); + ArgumentCaptor captor = ArgumentCaptor.forClass(SessionTreeLayout.class); + verify(sessionTreeLayoutRepository).save(captor.capture()); + assertEquals(1L, captor.getValue().getUserId().longValue()); + assertEquals("{\"nodes\":[]}", captor.getValue().getLayoutJson()); + } + + @Test + void saveLayoutCreatesEmptyPayloadWhenRequestIsNull() throws Exception { + when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty()); + when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}"); + + SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, null); + + assertEquals(Collections.emptyList(), result.getNodes()); + } +} diff --git a/frontend/src/api/sessionTree.ts b/frontend/src/api/sessionTree.ts new file mode 100644 index 0000000..c0aa0c1 --- /dev/null +++ b/frontend/src/api/sessionTree.ts @@ -0,0 +1,27 @@ +import client from './client' + +export type SessionTreeNodeType = 'folder' | 'connection' + +export interface SessionTreeNodePayload { + id: string + type: SessionTreeNodeType + name: string + parentId: string | null + order: number + connectionId?: number + expanded?: boolean + createdAt: number + updatedAt: number +} + +export interface SessionTreeLayoutPayload { + nodes: SessionTreeNodePayload[] +} + +export function getSessionTree() { + return client.get('/session-tree') +} + +export function saveSessionTree(payload: SessionTreeLayoutPayload) { + return client.put('/session-tree', payload) +} diff --git a/frontend/src/components/ContextMenu.vue b/frontend/src/components/ContextMenu.vue index b24f8a8..090b0a2 100644 --- a/frontend/src/components/ContextMenu.vue +++ b/frontend/src/components/ContextMenu.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/components/WorkspacePanel.vue b/frontend/src/components/WorkspacePanel.vue index 72c5051..3e345cd 100644 --- a/frontend/src/components/WorkspacePanel.vue +++ b/frontend/src/components/WorkspacePanel.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/composables/useConnectionSync.ts b/frontend/src/composables/useConnectionSync.ts index cf992c5..ac3ca16 100644 --- a/frontend/src/composables/useConnectionSync.ts +++ b/frontend/src/composables/useConnectionSync.ts @@ -10,6 +10,8 @@ export function useConnectionSync() { watch( () => connectionsStore.connections.length, (newLength, oldLength) => { + if (!treeStore.hydrated) return + if (newLength > oldLength) { // New connection added treeStore.syncNewConnections() @@ -24,6 +26,7 @@ export function useConnectionSync() { watch( () => connectionsStore.connections.map(c => ({ id: c.id, name: c.name })), (newConnections, oldConnections) => { + if (!treeStore.hydrated) return if (!oldConnections) return newConnections.forEach((newConn, index) => { diff --git a/frontend/src/composables/useTreeSearch.ts b/frontend/src/composables/useTreeSearch.ts index d9b3d1f..c0f0574 100644 --- a/frontend/src/composables/useTreeSearch.ts +++ b/frontend/src/composables/useTreeSearch.ts @@ -1,20 +1,24 @@ import { ref, computed, type Ref } from 'vue' +import { refDebounced } from '@vueuse/core' import type { SessionTreeNode } from '../types/sessionTree' export function useTreeSearch(nodesRef: Ref) { const searchQuery = ref('') + const debouncedQuery = refDebounced(searchQuery, 150) const searchResults = ref>(new Set()) const filteredNodes = computed(() => { const nodes = nodesRef.value - if (!searchQuery.value.trim()) { + const query = debouncedQuery.value.trim().toLowerCase() + if (!query) { + searchResults.value = new Set() return nodes } - const query = searchQuery.value.toLowerCase() const results = new Set() const matchedNodes = new Set() + const nodeById = new Map(nodes.map(node => [node.id, node])) // Find all matching nodes nodes.forEach(node => { @@ -23,10 +27,13 @@ export function useTreeSearch(nodesRef: Ref) { results.add(node.id) // Add all ancestors to results - let current = node + let current: SessionTreeNode | undefined = node while (current.parentId) { results.add(current.parentId) - current = nodes.find(n => n.id === current.parentId)! + current = nodeById.get(current.parentId) + if (!current) { + break + } } } }) diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index c577c94..f81fce7 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -1,96 +1,30 @@