From 3ff40ec82132c85b9e1a0a61f5c5822675b68266 Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:13:48 +0700 Subject: [PATCH 01/16] feat: add connection groups --- TablePro/Core/Storage/ConnectionStorage.swift | 15 +- TablePro/Core/Storage/GroupStorage.swift | 80 ++- TablePro/Models/ConnectionGroup.swift | 29 +- TablePro/Models/DatabaseConnection.swift | 8 +- TablePro/Resources/Localizable.xcstrings | 60 ++- .../Views/Connection/ConnectionFormView.swift | 12 +- .../Connection/ConnectionGroupEditor.swift | 141 ++++++ .../Connection/ConnectionGroupFormSheet.swift | 123 +++++ TablePro/Views/WelcomeWindowView.swift | 468 ++++++++++-------- 9 files changed, 719 insertions(+), 217 deletions(-) create mode 100644 TablePro/Views/Connection/ConnectionGroupEditor.swift create mode 100644 TablePro/Views/Connection/ConnectionGroupFormSheet.swift diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 1ceb6801..dad76262 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -358,6 +358,10 @@ private struct StoredConnection: Codable { // AI policy let aiPolicy: String? + // Group + let groupId: String? + let sortOrder: Int + init(from connection: DatabaseConnection) { self.id = connection.id self.name = connection.name @@ -392,6 +396,10 @@ private struct StoredConnection: Codable { // AI policy self.aiPolicy = connection.aiPolicy?.rawValue + + // Group + self.groupId = connection.groupId?.uuidString + self.sortOrder = connection.sortOrder } // Custom decoder to handle migration from old format @@ -428,6 +436,8 @@ private struct StoredConnection: Codable { groupId = try container.decodeIfPresent(String.self, forKey: .groupId) isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy) + groupId = try container.decodeIfPresent(String.self, forKey: .groupId) + sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 } func toConnection() -> DatabaseConnection { @@ -452,6 +462,7 @@ private struct StoredConnection: Codable { let parsedTagId = tagId.flatMap { UUID(uuidString: $0) } let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) } let parsedAIPolicy = aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) } + let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) } return DatabaseConnection( id: id, @@ -467,7 +478,9 @@ private struct StoredConnection: Codable { tagId: parsedTagId, groupId: parsedGroupId, isReadOnly: isReadOnly, - aiPolicy: parsedAIPolicy + aiPolicy: parsedAIPolicy, + groupId: parsedGroupId, + sortOrder: sortOrder ) } } diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index 4ef53f45..ef9a35b6 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -12,6 +12,7 @@ final class GroupStorage { private static let logger = Logger(subsystem: "com.TablePro", category: "GroupStorage") private let groupsKey = "com.TablePro.groups" + private let expandedGroupsKey = "com.TablePro.expandedGroups" private let defaults = UserDefaults.standard private let encoder = JSONEncoder() private let decoder = JSONDecoder() @@ -47,9 +48,6 @@ final class GroupStorage { /// Add a new group func addGroup(_ group: ConnectionGroup) { var groups = loadGroups() - guard !groups.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else { - return - } groups.append(group) saveGroups(groups) } @@ -63,15 +61,87 @@ final class GroupStorage { } } - /// Delete a group + /// Delete a group and all its descendants. + /// Member connections become ungrouped. func deleteGroup(_ group: ConnectionGroup) { var groups = loadGroups() - groups.removeAll { $0.id == group.id } + let deletedIds = collectDescendantIds(of: group.id, in: groups) + let allDeletedIds = deletedIds.union([group.id]) + + // Remove deleted groups + groups.removeAll { allDeletedIds.contains($0.id) } saveGroups(groups) + + // Ungroup connections that belonged to deleted groups + let storage = ConnectionStorage.shared + var connections = storage.loadConnections() + var changed = false + for index in connections.indices { + if let gid = connections[index].groupId, allDeletedIds.contains(gid) { + connections[index].groupId = nil + changed = true + } + } + if changed { + storage.saveConnections(connections) + } } /// Get group by ID func group(for id: UUID) -> ConnectionGroup? { loadGroups().first { $0.id == id } } + + /// Get child groups of a parent, sorted by sortOrder + func childGroups(of parentId: UUID?) -> [ConnectionGroup] { + loadGroups() + .filter { $0.parentGroupId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } + } + + /// Get the next sort order for a new item in a parent context + func nextSortOrder(parentId: UUID?) -> Int { + let siblings = loadGroups().filter { $0.parentGroupId == parentId } + return (siblings.map(\.sortOrder).max() ?? -1) + 1 + } + + // MARK: - Expanded State + + /// Load the set of expanded group IDs + func loadExpandedGroupIds() -> Set { + guard let data = defaults.data(forKey: expandedGroupsKey) else { + return [] + } + + do { + let ids = try decoder.decode([UUID].self, from: data) + return Set(ids) + } catch { + Self.logger.error("Failed to load expanded groups: \(error)") + return [] + } + } + + /// Save the set of expanded group IDs + func saveExpandedGroupIds(_ ids: Set) { + do { + let data = try encoder.encode(Array(ids)) + defaults.set(data, forKey: expandedGroupsKey) + } catch { + Self.logger.error("Failed to save expanded groups: \(error)") + } + } + + // MARK: - Helpers + + /// Recursively collect all descendant group IDs + private func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set { + var result = Set() + let children = groups.filter { $0.parentGroupId == groupId } + for child in children { + result.insert(child.id) + result.formUnion(collectDescendantIds(of: child.id, in: groups)) + } + return result + } } diff --git a/TablePro/Models/ConnectionGroup.swift b/TablePro/Models/ConnectionGroup.swift index 99164afb..0269db41 100644 --- a/TablePro/Models/ConnectionGroup.swift +++ b/TablePro/Models/ConnectionGroup.swift @@ -5,15 +5,40 @@ import Foundation -/// A named group (folder) for organizing database connections +/// A group for organizing database connections into folders struct ConnectionGroup: Identifiable, Hashable, Codable { let id: UUID var name: String var color: ConnectionColor + var parentGroupId: UUID? + var sortOrder: Int - init(id: UUID = UUID(), name: String, color: ConnectionColor = .none) { + init( + id: UUID = UUID(), + name: String, + color: ConnectionColor = .blue, + parentGroupId: UUID? = nil, + sortOrder: Int = 0 + ) { self.id = id self.name = name self.color = color + self.parentGroupId = parentGroupId + self.sortOrder = sortOrder + } + + // MARK: - Codable (Migration Support) + + enum CodingKeys: String, CodingKey { + case id, name, color, parentGroupId, sortOrder + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + color = try container.decodeIfPresent(ConnectionColor.self, forKey: .color) ?? .blue + parentGroupId = try container.decodeIfPresent(UUID.self, forKey: .parentGroupId) + sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 } } diff --git a/TablePro/Models/DatabaseConnection.swift b/TablePro/Models/DatabaseConnection.swift index 917e207f..20692495 100644 --- a/TablePro/Models/DatabaseConnection.swift +++ b/TablePro/Models/DatabaseConnection.swift @@ -253,6 +253,8 @@ struct DatabaseConnection: Identifiable, Hashable { var aiPolicy: AIConnectionPolicy? var mongoReadPreference: String? var mongoWriteConcern: String? + var groupId: UUID? + var sortOrder: Int init( id: UUID = UUID(), @@ -270,7 +272,9 @@ struct DatabaseConnection: Identifiable, Hashable { isReadOnly: Bool = false, aiPolicy: AIConnectionPolicy? = nil, mongoReadPreference: String? = nil, - mongoWriteConcern: String? = nil + mongoWriteConcern: String? = nil, + groupId: UUID? = nil, + sortOrder: Int = 0 ) { self.id = id self.name = name @@ -288,6 +292,8 @@ struct DatabaseConnection: Identifiable, Hashable { self.aiPolicy = aiPolicy self.mongoReadPreference = mongoReadPreference self.mongoWriteConcern = mongoWriteConcern + self.groupId = groupId + self.sortOrder = sortOrder } /// Returns the display color (custom color or database type color) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 2f1074cb..4b483457 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -289,6 +289,18 @@ } } }, + "%@%@" : { + "comment" : "A button label that includes a dot representing the group's color, followed by the group's name", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@%2$@" + } + } + } + }, "%@ms" : { "localizations" : { "vi" : { @@ -1225,6 +1237,10 @@ } } }, + "Are you sure you want to delete \"%@\"? Connections will be ungrouped." : { + "comment" : "A confirmation dialog message asking the user to confirm the deletion of a connection group.", + "isCommentAutoGenerated" : true + }, "Are you sure you want to disconnect from this database?" : { "localizations" : { "vi" : { @@ -2396,12 +2412,10 @@ } } } - }, - "Create New Group" : { - }, "Create New Group..." : { - + "comment" : "A menu item that allows users to create a new connection group.", + "isCommentAutoGenerated" : true }, "Create New Tag" : { "localizations" : { @@ -2959,7 +2973,8 @@ } }, "Delete Group" : { - + "comment" : "A confirmation dialog title for deleting a database connection group.", + "isCommentAutoGenerated" : true }, "Delete Index" : { "extractionState" : "stale", @@ -3234,6 +3249,14 @@ } } }, + "Edit Group" : { + "comment" : "A label indicating that the view is for editing a group.", + "isCommentAutoGenerated" : true + }, + "Edit Group..." : { + "comment" : "A button label for editing an existing group.", + "isCommentAutoGenerated" : true + }, "Edit Provider" : { "localizations" : { "vi" : { @@ -4311,10 +4334,12 @@ } }, "Group" : { - + "comment" : "A label for editing the user's connection group.", + "isCommentAutoGenerated" : true }, "Group name" : { - + "comment" : "A label for the text field that lets them enter a name for the group.", + "isCommentAutoGenerated" : true }, "Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : { "localizations" : { @@ -5061,6 +5086,10 @@ } } }, + "Manage Groups" : { + "comment" : "A menu header that allows users to manage their connection groups.", + "isCommentAutoGenerated" : true + }, "Manage Tags" : { "localizations" : { "vi" : { @@ -5229,6 +5258,10 @@ } } }, + "Move to Group" : { + "comment" : "A menu title that allows users to move a connection to a different group.", + "isCommentAutoGenerated" : true + }, "Move Up" : { "extractionState" : "stale", "localizations" : { @@ -5347,7 +5380,12 @@ } }, "New Group" : { - + "comment" : "A title for a form sheet used to create a new connection group.", + "isCommentAutoGenerated" : true + }, + "New Group..." : { + "comment" : "A menu item that allows users to create a new group.", + "isCommentAutoGenerated" : true }, "New query tab" : { "extractionState" : "stale", @@ -5381,6 +5419,10 @@ } } }, + "New Subgroup..." : { + "comment" : "A menu item that allows creating a new subgroup within an existing group.", + "isCommentAutoGenerated" : true + }, "New Tab" : { "localizations" : { "vi" : { @@ -9171,5 +9213,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 0cbfea43..c9213043 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -52,7 +52,7 @@ struct ConnectionFormView: View { @State private var sslClientCertPath: String = "" @State private var sslClientKeyPath: String = "" - // Color and Tag + // Color, Tag, and Group @State private var connectionColor: ConnectionColor = .none @State private var selectedTagId: UUID? @State private var selectedGroupId: UUID? @@ -224,7 +224,7 @@ struct ConnectionFormView: View { ConnectionTagEditor(selectedTagId: $selectedTagId) } LabeledContent(String(localized: "Group")) { - ConnectionGroupPicker(selectedGroupId: $selectedGroupId) + ConnectionGroupEditor(selectedGroupId: $selectedGroupId) } Toggle(String(localized: "Read-Only"), isOn: $isReadOnly) .help("Prevent write operations (INSERT, UPDATE, DELETE, DROP, etc.)") @@ -636,11 +636,11 @@ struct ConnectionFormView: View { sslConfig: sslConfig, color: connectionColor, tagId: selectedTagId, - groupId: selectedGroupId, isReadOnly: isReadOnly, aiPolicy: aiPolicy, mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, - mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern + mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, + groupId: selectedGroupId ) // Save passwords to Keychain @@ -735,9 +735,9 @@ struct ConnectionFormView: View { sslConfig: sslConfig, color: connectionColor, tagId: selectedTagId, - groupId: selectedGroupId, mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, - mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern + mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, + groupId: selectedGroupId ) Task { diff --git a/TablePro/Views/Connection/ConnectionGroupEditor.swift b/TablePro/Views/Connection/ConnectionGroupEditor.swift new file mode 100644 index 00000000..abb80a2f --- /dev/null +++ b/TablePro/Views/Connection/ConnectionGroupEditor.swift @@ -0,0 +1,141 @@ +// +// ConnectionGroupEditor.swift +// TablePro +// + +import SwiftUI + +/// Group selection dropdown for the connection form +struct ConnectionGroupEditor: View { + @Binding var selectedGroupId: UUID? + @State private var allGroups: [ConnectionGroup] = [] + @State private var showingCreateSheet = false + + private let groupStorage = GroupStorage.shared + + private var selectedGroup: ConnectionGroup? { + guard let id = selectedGroupId else { return nil } + return groupStorage.group(for: id) + } + + var body: some View { + Menu { + Button { + selectedGroupId = nil + } label: { + HStack { + Text("None") + if selectedGroupId == nil { + Spacer() + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(sortedGroupsFlat(), id: \.group.id) { item in + Button { + selectedGroupId = item.group.id + } label: { + HStack { + Image(nsImage: colorDot(item.group.color.color)) + Text("\(item.prefix)\(item.group.name)") + if selectedGroupId == item.group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + + Divider() + + Button { + showingCreateSheet = true + } label: { + Label("Create New Group...", systemImage: "plus.circle") + } + + if allGroups.contains(where: { _ in true }) { + Divider() + + Menu("Manage Groups") { + ForEach(allGroups) { group in + Button(role: .destructive) { + deleteGroup(group) + } label: { + Label("Delete \"\(group.name)\"", systemImage: "trash") + } + } + } + } + } label: { + HStack(spacing: 6) { + if let group = selectedGroup { + Image(systemName: "folder.fill") + .foregroundStyle(group.color.color) + .font(.system(size: 10)) + Text(group.name) + .foregroundStyle(.primary) + } else { + Text("None") + .foregroundStyle(.secondary) + } + } + } + .menuStyle(.borderlessButton) + .fixedSize() + .task { allGroups = groupStorage.loadGroups() } + .sheet(isPresented: $showingCreateSheet) { + ConnectionGroupFormSheet { newGroup in + groupStorage.addGroup(newGroup) + selectedGroupId = newGroup.id + allGroups = groupStorage.loadGroups() + } + } + } + + // MARK: - Helpers + + private struct FlatGroupItem { + let group: ConnectionGroup + let prefix: String + } + + private func sortedGroupsFlat() -> [FlatGroupItem] { + var result: [FlatGroupItem] = [] + func walk(_ parentId: UUID?, depth: Int) { + let children = allGroups + .filter { $0.parentGroupId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } + for child in children { + let prefix = String(repeating: " ", count: depth) + result.append(FlatGroupItem(group: child, prefix: prefix)) + walk(child.id, depth: depth + 1) + } + } + walk(nil, depth: 0) + return result + } + + /// Create a colored circle NSImage for use in menu items + private func colorDot(_ color: Color) -> NSImage { + let size = NSSize(width: 10, height: 10) + let image = NSImage(size: size, flipped: false) { rect in + NSColor(color).setFill() + NSBezierPath(ovalIn: rect).fill() + return true + } + image.isTemplate = false + return image + } + + private func deleteGroup(_ group: ConnectionGroup) { + if selectedGroupId == group.id { + selectedGroupId = nil + } + groupStorage.deleteGroup(group) + allGroups = groupStorage.loadGroups() + } +} diff --git a/TablePro/Views/Connection/ConnectionGroupFormSheet.swift b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift new file mode 100644 index 00000000..5d05688b --- /dev/null +++ b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift @@ -0,0 +1,123 @@ +// +// ConnectionGroupFormSheet.swift +// TablePro +// + +import SwiftUI + +/// Sheet for creating or editing a connection group +struct ConnectionGroupFormSheet: View { + @Environment(\.dismiss) private var dismiss + + let group: ConnectionGroup? + let parentGroupId: UUID? + var onSave: ((ConnectionGroup) -> Void)? + + @State private var name: String = "" + @State private var color: ConnectionColor = .blue + + init( + group: ConnectionGroup? = nil, + parentGroupId: UUID? = nil, + onSave: ((ConnectionGroup) -> Void)? = nil + ) { + self.group = group + self.parentGroupId = parentGroupId + self.onSave = onSave + } + + var body: some View { + VStack(spacing: 16) { + Text(group == nil ? String(localized: "New Group") : String(localized: "Edit Group")) + .font(.headline) + + TextField(String(localized: "Group name"), text: $name) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + + VStack(alignment: .leading, spacing: 6) { + Text("Color") + .font(.caption) + .foregroundStyle(.secondary) + GroupColorPicker(selectedColor: $color) + } + + HStack { + Button("Cancel") { + dismiss() + } + + Button(group == nil ? String(localized: "Create") : String(localized: "Save")) { + save() + } + .keyboardShortcut(.return) + .buttonStyle(.borderedProminent) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(20) + .frame(width: 300) + .onAppear { + if let group { + name = group.name + color = group.color + } + } + .onExitCommand { + dismiss() + } + } + + private func save() { + let trimmedName = name.trimmingCharacters(in: .whitespaces) + guard !trimmedName.isEmpty else { return } + + if var existing = group { + existing.name = trimmedName + existing.color = color + onSave?(existing) + } else { + let sortOrder = GroupStorage.shared.nextSortOrder(parentId: parentGroupId) + let newGroup = ConnectionGroup( + name: trimmedName, + color: color, + parentGroupId: parentGroupId, + sortOrder: sortOrder + ) + onSave?(newGroup) + } + dismiss() + } +} + +// MARK: - Group Color Picker + +/// Color picker for groups (excludes "none" option) +private struct GroupColorPicker: View { + @Binding var selectedColor: ConnectionColor + + private var availableColors: [ConnectionColor] { + ConnectionColor.allCases.filter { $0 != .none } + } + + var body: some View { + HStack(spacing: 6) { + ForEach(availableColors) { color in + Circle() + .fill(color.color) + .frame(width: DesignConstants.IconSize.medium, height: DesignConstants.IconSize.medium) + .overlay( + Circle() + .stroke(Color.primary, lineWidth: selectedColor == color ? 2 : 0) + .frame( + width: DesignConstants.IconSize.large, + height: DesignConstants.IconSize.large + ) + ) + .onTapGesture { + selectedColor = color + } + } + } + } +} diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index 8a566c20..f21724c5 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -15,7 +15,6 @@ import SwiftUI struct WelcomeWindowView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "WelcomeWindowView") private let storage = ConnectionStorage.shared - private let groupStorage = GroupStorage.shared @ObservedObject private var dbManager = DatabaseManager.shared @State private var connections: [DatabaseConnection] = [] @@ -28,12 +27,17 @@ struct WelcomeWindowView: View { @State private var hoveredConnectionId: UUID? @State private var selectedConnectionId: UUID? // For keyboard navigation @State private var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() + + // Group state @State private var groups: [ConnectionGroup] = [] - @State private var collapsedGroupIds: Set = { - let strings = UserDefaults.standard.stringArray(forKey: "com.TablePro.collapsedGroupIds") ?? [] - return Set(strings.compactMap { UUID(uuidString: $0) }) - }() + @State private var expandedGroups: Set = [] @State private var showNewGroupSheet = false + @State private var groupToEdit: ConnectionGroup? + @State private var groupToDelete: ConnectionGroup? + @State private var showDeleteGroupConfirmation = false + @State private var newGroupParentId: UUID? + + private let groupStorage = GroupStorage.shared @Environment(\.openWindow) private var openWindow @@ -45,28 +49,9 @@ struct WelcomeWindowView: View { connection.name.localizedCaseInsensitiveContains(searchText) || connection.host.localizedCaseInsensitiveContains(searchText) || connection.database.localizedCaseInsensitiveContains(searchText) - || groupName(for: connection.groupId)?.localizedCaseInsensitiveContains(searchText) == true } } - private func groupName(for groupId: UUID?) -> String? { - guard let groupId else { return nil } - return groups.first { $0.id == groupId }?.name - } - - private var ungroupedConnections: [DatabaseConnection] { - filteredConnections.filter { $0.groupId == nil } - } - - private var activeGroups: [ConnectionGroup] { - let groupIds = Set(filteredConnections.compactMap(\.groupId)) - return groups.filter { groupIds.contains($0.id) } - } - - private func connections(in group: ConnectionGroup) -> [DatabaseConnection] { - filteredConnections.filter { $0.groupId == group.id } - } - var body: some View { ZStack { if showOnboarding { @@ -105,11 +90,33 @@ struct WelcomeWindowView: View { .onReceive(NotificationCenter.default.publisher(for: .connectionUpdated)) { _ in loadConnections() } + .confirmationDialog( + "Delete Group", + isPresented: $showDeleteGroupConfirmation, + presenting: groupToDelete + ) { group in + Button("Delete", role: .destructive) { + deleteGroup(group) + } + Button("Cancel", role: .cancel) {} + } message: { group in + Text("Are you sure you want to delete \"\(group.name)\"? Connections will be ungrouped.") + } .sheet(isPresented: $showNewGroupSheet) { - CreateGroupSheet { name, color in - let group = ConnectionGroup(name: name, color: color) - groupStorage.addGroup(group) - groups = groupStorage.loadGroups() + ConnectionGroupFormSheet( + group: groupToEdit, + parentGroupId: newGroupParentId + ) { group in + if groupToEdit != nil { + groupStorage.updateGroup(group) + } else { + groupStorage.addGroup(group) + expandedGroups.insert(group.id) + groupStorage.saveExpandedGroupIds(expandedGroups) + } + groupToEdit = nil + newGroupParentId = nil + loadConnections() } } } @@ -201,22 +208,6 @@ struct WelcomeWindowView: View { .buttonStyle(.plain) .help("New Connection (⌘N)") - Button(action: { showNewGroupSheet = true }) { - Image(systemName: "folder.badge.plus") - .font(.system(size: DesignConstants.FontSize.medium, weight: .medium)) - .foregroundStyle(.secondary) - .frame( - width: DesignConstants.IconSize.extraLarge, - height: DesignConstants.IconSize.extraLarge - ) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .quaternaryLabelColor)) - ) - } - .buttonStyle(.plain) - .help(String(localized: "New Group")) - HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: DesignConstants.FontSize.medium)) @@ -255,40 +246,50 @@ struct WelcomeWindowView: View { Button(action: { openWindow(id: "connection-form") }) { Label("New Connection...", systemImage: "plus") } + Button(action: { + groupToEdit = nil + newGroupParentId = nil + showNewGroupSheet = true + }) { + Label("New Group...", systemImage: "folder.badge.plus") + } } // MARK: - Connection List - /// Connection list that behaves like native NSTableView: - /// - Single click: selects row (handled by List's selection binding) - /// - Double click: connects to database (via simultaneousGesture in ConnectionRow) - /// - Return key: connects to selected row - /// - Arrow keys: native keyboard navigation + /// Connection list with group hierarchy support. + /// When searching: flat filtered list. Otherwise: grouped with DisclosureGroups. private var connectionList: some View { List(selection: $selectedConnectionId) { - ForEach(ungroupedConnections) { connection in - connectionRow(for: connection) - } - .onMove { from, to in - guard searchText.isEmpty else { return } - moveUngroupedConnections(from: from, to: to) - } + if !searchText.isEmpty { + // Flat filtered list during search + ForEach(filteredConnections) { connection in + connectionRow(for: connection) + } + } else { + // Grouped hierarchical view + ForEach(rootGroups) { group in + groupSection(for: group) + } + .onMove { from, to in + moveRootGroups(from: from, to: to) + } - ForEach(activeGroups) { group in - Section { - if !collapsedGroupIds.contains(group.id) { - ForEach(connections(in: group)) { connection in - connectionRow(for: connection) - } - } - } header: { - groupHeader(for: group) + // Ungrouped connections + ForEach(ungroupedConnections) { connection in + connectionRow(for: connection) + } + .onMove { from, to in + moveUngroupedConnections(from: from, to: to) } } } .listStyle(.inset) .scrollContentBackground(.hidden) .environment(\.defaultMinListRowHeight, 44) + .dropDestination(for: String.self) { items, _ in + handleDropOnGroup(items: items, targetGroupId: nil) + } .onKeyPress(.return) { if let id = selectedConnectionId, let connection = connections.first(where: { $0.id == id }) @@ -299,104 +300,150 @@ struct WelcomeWindowView: View { } } - private func connectionRow(for connection: DatabaseConnection) -> some View { - ConnectionRow( - connection: connection, - onConnect: { connectToDatabase(connection) }, - onEdit: { - openWindow(id: "connection-form", value: connection.id as UUID?) - focusConnectionFormWindow() - }, - onDuplicate: { - duplicateConnection(connection) - }, - onDelete: { - connectionToDelete = connection - showDeleteConfirmation = true + // MARK: - Group Hierarchy + + private var rootGroups: [ConnectionGroup] { + groups.filter { $0.parentGroupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + } + + private var ungroupedConnections: [DatabaseConnection] { + connections.filter { $0.groupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + } + + private func childGroups(of parentId: UUID) -> [ConnectionGroup] { + groups.filter { $0.parentGroupId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } + } + + private func connectionsInGroup(_ groupId: UUID) -> [DatabaseConnection] { + connections.filter { $0.groupId == groupId } + .sorted { $0.sortOrder < $1.sortOrder } + } + + private func totalConnectionCount(in group: ConnectionGroup) -> Int { + let direct = connections.filter { $0.groupId == group.id }.count + let children = childGroups(of: group.id) + let nested = children.reduce(0) { $0 + totalConnectionCount(in: $1) } + return direct + nested + } + + private func expandedBinding(_ groupId: UUID) -> Binding { + Binding( + get: { expandedGroups.contains(groupId) }, + set: { isExpanded in + if isExpanded { + expandedGroups.insert(groupId) + } else { + expandedGroups.remove(groupId) + } + groupStorage.saveExpandedGroupIds(expandedGroups) } ) - .tag(connection.id) - .listRowInsets(DesignConstants.swiftUIListRowInsets) - .listRowSeparator(.hidden) } - private func groupHeader(for group: ConnectionGroup) -> some View { - HStack(spacing: 6) { - Image(systemName: collapsedGroupIds.contains(group.id) ? "chevron.right" : "chevron.down") - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) - .foregroundStyle(.tertiary) - .frame(width: 12) - - if !group.color.isDefault { - Circle() - .fill(group.color.color) - .frame(width: 8, height: 8) + private func groupSection(for group: ConnectionGroup) -> AnyView { + AnyView( + DisclosureGroup(isExpanded: expandedBinding(group.id)) { + ForEach(childGroups(of: group.id)) { child in + groupSection(for: child) + } + ForEach(connectionsInGroup(group.id)) { connection in + connectionRow(for: connection) + } + } label: { + groupRowLabel(group) } + ) + } + + private func groupRowLabel(_ group: ConnectionGroup) -> some View { + HStack(spacing: 8) { + Image(systemName: "folder.fill") + .foregroundStyle(group.color.isDefault ? .secondary : group.color.color) + .font(.system(size: DesignConstants.FontSize.body)) Text(group.name) - .font(.system(size: DesignConstants.FontSize.small, weight: .semibold)) - .foregroundStyle(.secondary) + .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .foregroundStyle(.primary) - Text("\(connections(in: group).count)") - .font(.system(size: DesignConstants.FontSize.tiny)) + Text("\(totalConnectionCount(in: group))") + .font(.system(size: DesignConstants.FontSize.small)) .foregroundStyle(.tertiary) Spacer() } .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - if collapsedGroupIds.contains(group.id) { - collapsedGroupIds.remove(group.id) + .overlay( + DoubleClickView { + if expandedGroups.contains(group.id) { + expandedGroups.remove(group.id) } else { - collapsedGroupIds.insert(group.id) + expandedGroups.insert(group.id) } - UserDefaults.standard.set( - Array(collapsedGroupIds.map(\.uuidString)), - forKey: "com.TablePro.collapsedGroupIds" - ) + groupStorage.saveExpandedGroupIds(expandedGroups) } + ) + .dropDestination(for: String.self) { items, _ in + handleDropOnGroup(items: items, targetGroupId: group.id) } .contextMenu { - Button { - renameGroup(group) - } label: { - Label(String(localized: "Rename"), systemImage: "pencil") + Button(action: { openWindow(id: "connection-form") }) { + Label("New Connection...", systemImage: "plus") } - - Menu(String(localized: "Change Color")) { - ForEach(ConnectionColor.allCases) { color in - Button { - var updated = group - updated.color = color - groupStorage.updateGroup(updated) - groups = groupStorage.loadGroups() - } label: { - HStack { - if color != .none { - Image(systemName: "circle.fill") - .foregroundStyle(color.color) - } - Text(color.displayName) - if group.color == color { - Spacer() - Image(systemName: "checkmark") - } - } - } - } + Button(action: { + groupToEdit = nil + newGroupParentId = group.id + showNewGroupSheet = true + }) { + Label("New Subgroup...", systemImage: "folder.badge.plus") + } + Divider() + Button(action: { + groupToEdit = group + newGroupParentId = group.parentGroupId + showNewGroupSheet = true + }) { + Label("Edit Group...", systemImage: "pencil") } - Divider() - Button(role: .destructive) { - deleteGroup(group) + groupToDelete = group + showDeleteGroupConfirmation = true } label: { - Label(String(localized: "Delete Group"), systemImage: "trash") + Label("Delete Group", systemImage: "trash") } } } + @ViewBuilder + private func connectionRow(for connection: DatabaseConnection) -> some View { + ConnectionRow( + connection: connection, + groups: groups, + onConnect: { connectToDatabase(connection) }, + onEdit: { + openWindow(id: "connection-form", value: connection.id as UUID?) + focusConnectionFormWindow() + }, + onDuplicate: { + duplicateConnection(connection) + }, + onDelete: { + connectionToDelete = connection + showDeleteConfirmation = true + }, + onMoveToGroup: { groupId in + moveConnectionToGroup(connection, groupId: groupId) + } + ) + .tag(connection.id) + .listRowInsets(DesignConstants.swiftUIListRowInsets) + .listRowSeparator(.hidden) + .draggable(connection.id.uuidString) + } + // MARK: - Empty State private var emptyState: some View { @@ -436,7 +483,10 @@ struct WelcomeWindowView: View { } else { connections = saved } - loadGroups() + groups = groupStorage.loadGroups() + let savedExpanded = groupStorage.loadExpandedGroupIds() + // Auto-expand new groups + expandedGroups = savedExpanded.union(Set(groups.map(\.id))) } private func connectToDatabase(_ connection: DatabaseConnection) { @@ -482,61 +532,6 @@ struct WelcomeWindowView: View { focusConnectionFormWindow() } - private func loadGroups() { - groups = groupStorage.loadGroups() - } - - private func deleteGroup(_ group: ConnectionGroup) { - for i in connections.indices where connections[i].groupId == group.id { - connections[i].groupId = nil - } - storage.saveConnections(connections) - groupStorage.deleteGroup(group) - groups = groupStorage.loadGroups() - } - - private func renameGroup(_ group: ConnectionGroup) { - let alert = NSAlert() - alert.messageText = String(localized: "Rename Group") - alert.informativeText = String(localized: "Enter a new name for the group.") - alert.addButton(withTitle: String(localized: "Rename")) - alert.addButton(withTitle: String(localized: "Cancel")) - - let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - textField.stringValue = group.name - alert.accessoryView = textField - - if alert.runModal() == .alertFirstButtonReturn { - let newName = textField.stringValue.trimmingCharacters(in: .whitespaces) - guard !newName.isEmpty else { return } - let isDuplicate = groups.contains { - $0.id != group.id && $0.name.lowercased() == newName.lowercased() - } - guard !isDuplicate else { return } - var updated = group - updated.name = newName - groupStorage.updateGroup(updated) - groups = groupStorage.loadGroups() - } - } - - private func moveUngroupedConnections(from source: IndexSet, to destination: Int) { - let ungroupedIndices = connections.indices.filter { connections[$0].groupId == nil } - - let globalSource = IndexSet(source.map { ungroupedIndices[$0] }) - let globalDestination: Int - if destination < ungroupedIndices.count { - globalDestination = ungroupedIndices[destination] - } else if let last = ungroupedIndices.last { - globalDestination = last + 1 - } else { - globalDestination = 0 - } - - connections.move(fromOffsets: globalSource, toOffset: globalDestination) - storage.saveConnections(connections) - } - /// Focus the connection form window as soon as it's available private func focusConnectionFormWindow() { // Poll rapidly until window is found (much faster than fixed delay) @@ -561,16 +556,71 @@ struct WelcomeWindowView: View { attemptFocus() } } + + private func deleteGroup(_ group: ConnectionGroup) { + groupStorage.deleteGroup(group) + expandedGroups.remove(group.id) + groupStorage.saveExpandedGroupIds(expandedGroups) + loadConnections() + } + + private func moveConnectionToGroup(_ connection: DatabaseConnection, groupId: UUID?) { + var updated = connection + updated.groupId = groupId + storage.updateConnection(updated) + loadConnections() + } + + private func moveUngroupedConnections(from: IndexSet, to: Int) { + var ungrouped = connections.filter { $0.groupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + ungrouped.move(fromOffsets: from, toOffset: to) + for (index, var conn) in ungrouped.enumerated() { + conn.sortOrder = index + storage.updateConnection(conn) + } + loadConnections() + } + + private func moveRootGroups(from: IndexSet, to: Int) { + var roots = groups.filter { $0.parentGroupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + roots.move(fromOffsets: from, toOffset: to) + for (index, var group) in roots.enumerated() { + group.sortOrder = index + groupStorage.updateGroup(group) + } + loadConnections() + } + + private func handleDropOnGroup(items: [String], targetGroupId: UUID?) -> Bool { + var changed = false + for item in items { + guard let uuid = UUID(uuidString: item) else { continue } + if var conn = connections.first(where: { $0.id == uuid }) { + guard conn.groupId != targetGroupId else { continue } + conn.groupId = targetGroupId + storage.updateConnection(conn) + changed = true + } + } + if changed { + loadConnections() + } + return changed + } } // MARK: - ConnectionRow private struct ConnectionRow: View { let connection: DatabaseConnection + var groups: [ConnectionGroup] = [] var onConnect: (() -> Void)? var onEdit: (() -> Void)? var onDuplicate: (() -> Void)? var onDelete: (() -> Void)? + var onMoveToGroup: ((UUID?) -> Void)? private var displayTag: ConnectionTag? { guard let tagId = connection.tagId else { return nil } @@ -640,6 +690,38 @@ private struct ConnectionRow: View { } } + if !groups.isEmpty, let onMoveToGroup = onMoveToGroup { + Divider() + Menu("Move to Group") { + Button { + onMoveToGroup(nil) + } label: { + HStack { + Text("None") + if connection.groupId == nil { + Spacer() + Image(systemName: "checkmark") + } + } + } + Divider() + ForEach(groups.filter { $0.parentGroupId == nil }.sorted { $0.sortOrder < $1.sortOrder }) { group in + Button { + onMoveToGroup(group.id) + } label: { + HStack { + Image(systemName: "folder.fill") + Text(group.name) + if connection.groupId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } + } + if let onDelete = onDelete { Divider() Button(role: .destructive, action: onDelete) { @@ -763,7 +845,7 @@ private class PassThroughDoubleClickView: NSView { var onDoubleClick: (() -> Void)? override func mouseDown(with event: NSEvent) { - if event.clickCount == 2 { + if event.clickCount >= 2, event.clickCount % 2 == 0 { onDoubleClick?() } // Always forward to next responder for List selection From 4c5d8e2675ecfb776c1480d3e82db53b0dd03bcd Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:27:02 +0700 Subject: [PATCH 02/16] update --- .../WelcomeWindow/ConnectionOutlineView.swift | 1278 +++++++++++++++++ TablePro/Views/WelcomeWindowView.swift | 481 +------ 2 files changed, 1336 insertions(+), 423 deletions(-) create mode 100644 TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift new file mode 100644 index 00000000..5819efdb --- /dev/null +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -0,0 +1,1278 @@ +// +// ConnectionOutlineView.swift +// TablePro +// +// NSViewRepresentable wrapping NSOutlineView for hierarchical connection list +// with drag-and-drop reordering and group management. +// + +import AppKit +import os +import SwiftUI + +// MARK: - Outline Item Wrappers + +/// Reference-type wrapper for ConnectionGroup (NSOutlineView requires objects) +final class OutlineGroup: NSObject { + let group: ConnectionGroup + init(_ group: ConnectionGroup) { + self.group = group + } +} + +/// Reference-type wrapper for DatabaseConnection (NSOutlineView requires objects) +final class OutlineConnection: NSObject { + let connection: DatabaseConnection + init(_ connection: DatabaseConnection) { + self.connection = connection + } +} + +// MARK: - ConnectionOutlineView + +struct ConnectionOutlineView: NSViewRepresentable { + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionOutlineView") + + let groups: [ConnectionGroup] + let connections: [DatabaseConnection] + var expandedGroupIds: Set + var selectedItemId: UUID? + var searchText: String + + // Callbacks + var onSelectionChanged: ((UUID?) -> Void)? + var onDoubleClickConnection: ((DatabaseConnection) -> Void)? + var onToggleGroup: ((UUID) -> Void)? + var onMoveConnection: ((DatabaseConnection, UUID?) -> Void)? + var onReorderConnections: (([DatabaseConnection]) -> Void)? + var onReorderGroups: (([ConnectionGroup]) -> Void)? + var onMoveGroup: ((ConnectionGroup, UUID?) -> Void)? + + // Context menu callbacks + var onNewConnection: (() -> Void)? + var onNewGroup: ((UUID?) -> Void)? + var onEditGroup: ((ConnectionGroup) -> Void)? + var onDeleteGroup: ((ConnectionGroup) -> Void)? + var onEditConnection: ((DatabaseConnection) -> Void)? + var onDuplicateConnection: ((DatabaseConnection) -> Void)? + var onDeleteConnection: ((DatabaseConnection) -> Void)? + var onMoveConnectionToGroup: ((DatabaseConnection, UUID?) -> Void)? + + // MARK: - NSViewRepresentable + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> NSScrollView { + let outlineView = ConnectionNSOutlineView() + outlineView.coordinator = context.coordinator + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("MainColumn")) + column.title = "" + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + + outlineView.headerView = nil + outlineView.rowHeight = DesignConstants.RowHeight.comfortable + outlineView.style = .sourceList + outlineView.selectionHighlightStyle = .regular + outlineView.allowsMultipleSelection = false + outlineView.autosaveExpandedItems = false + outlineView.floatsGroupRows = false + outlineView.rowSizeStyle = .default + outlineView.usesAutomaticRowHeights = false + outlineView.indentationPerLevel = 20 + outlineView.backgroundColor = .controlBackgroundColor + + outlineView.registerForDraggedTypes([.outlineItem]) + outlineView.setDraggingSourceOperationMask(.move, forLocal: true) + + outlineView.dataSource = context.coordinator + outlineView.delegate = context.coordinator + outlineView.target = context.coordinator + outlineView.doubleAction = #selector(Coordinator.handleDoubleClick) + + let scrollView = NSScrollView() + scrollView.documentView = outlineView + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.automaticallyAdjustsContentInsets = false + + context.coordinator.outlineView = outlineView + context.coordinator.rebuildData(groups: groups, connections: connections, searchText: searchText) + outlineView.reloadData() + syncExpandedState(outlineView: outlineView, coordinator: context.coordinator) + syncSelection(outlineView: outlineView, coordinator: context.coordinator) + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let outlineView = scrollView.documentView as? NSOutlineView else { return } + + let coordinator = context.coordinator + coordinator.parent = self + + // Skip reload during active drag to avoid lag + guard !coordinator.isDragging else { return } + + let needsReload = coordinator.needsReload( + groups: groups, + connections: connections, + searchText: searchText + ) + + if needsReload { + coordinator.rebuildData(groups: groups, connections: connections, searchText: searchText) + outlineView.reloadData() + syncExpandedState(outlineView: outlineView, coordinator: coordinator) + } + + syncSelection(outlineView: outlineView, coordinator: coordinator) + } + + // MARK: - State Sync + + private func syncExpandedState(outlineView: NSOutlineView, coordinator: Coordinator) { + NSAnimationContext.beginGrouping() + NSAnimationContext.current.duration = 0 + + for item in coordinator.rootItems { + syncExpandedStateRecursive(outlineView: outlineView, item: item, coordinator: coordinator) + } + + NSAnimationContext.endGrouping() + } + + private func syncExpandedStateRecursive( + outlineView: NSOutlineView, + item: NSObject, + coordinator: Coordinator + ) { + guard let outlineGroup = item as? OutlineGroup else { return } + let shouldExpand = expandedGroupIds.contains(outlineGroup.group.id) + let isExpanded = outlineView.isItemExpanded(item) + + if shouldExpand && !isExpanded { + outlineView.expandItem(item) + } else if !shouldExpand && isExpanded { + outlineView.collapseItem(item) + } + + // Recurse into children + if let children = coordinator.childrenMap[outlineGroup.group.id] { + for child in children { + syncExpandedStateRecursive(outlineView: outlineView, item: child, coordinator: coordinator) + } + } + } + + private func syncSelection(outlineView: NSOutlineView, coordinator: Coordinator) { + guard let targetId = selectedItemId else { + if outlineView.selectedRow != -1 { + outlineView.deselectAll(nil) + } + return + } + + if let item = coordinator.itemById(targetId) { + let row = outlineView.row(forItem: item) + if row >= 0 && outlineView.selectedRow != row { + coordinator.isSyncingSelection = true + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + coordinator.isSyncingSelection = false + } + } + } +} + +// MARK: - Pasteboard Type + +private extension NSPasteboard.PasteboardType { + static let outlineItem = NSPasteboard.PasteboardType("com.TablePro.outlineItem") +} + +// MARK: - Cell View Identifiers + +private extension NSUserInterfaceItemIdentifier { + static let groupCell = NSUserInterfaceItemIdentifier("GroupCell") + static let connectionCell = NSUserInterfaceItemIdentifier("ConnectionCell") +} + +// MARK: - Reusable Cell Views + +/// Cell view for group rows — subviews are created once and updated on reuse +private final class GroupCellView: NSTableCellView { + let folderIcon = NSImageView() + let nameLabel = NSTextField(labelWithString: "") + let countLabel = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + identifier = .groupCell + setupViews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } + + private func setupViews() { + let iconSize = DesignConstants.IconSize.medium + + folderIcon.setContentHuggingPriority(.defaultHigh, for: .horizontal) + folderIcon.translatesAutoresizingMaskIntoConstraints = false + folderIcon.widthAnchor.constraint(equalToConstant: iconSize).isActive = true + folderIcon.heightAnchor.constraint(equalToConstant: iconSize).isActive = true + + nameLabel.font = NSFont.systemFont(ofSize: DesignConstants.FontSize.body, weight: .medium) + nameLabel.textColor = .labelColor + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + countLabel.font = NSFont.systemFont(ofSize: DesignConstants.FontSize.small) + countLabel.textColor = .tertiaryLabelColor + countLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let container = NSStackView(views: [folderIcon, nameLabel, countLabel]) + container.orientation = .horizontal + container.alignment = .centerY + container.spacing = DesignConstants.Spacing.xs + container.translatesAutoresizingMaskIntoConstraints = false + + addSubview(container) + NSLayoutConstraint.activate([ + container.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DesignConstants.Spacing.xxs), + container.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -DesignConstants.Spacing.xs), + container.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + textField = nameLabel + imageView = folderIcon + } + + func configure(group: ConnectionGroup, connectionCount: Int) { + let folderImage = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil) + folderIcon.image = folderImage + folderIcon.contentTintColor = group.color.isDefault ? .secondaryLabelColor : NSColor(group.color.color) + nameLabel.stringValue = group.name + countLabel.stringValue = "\(connectionCount)" + } +} + +/// Cell view for connection rows — subviews are created once and updated on reuse +private final class ConnectionCellView: NSTableCellView { + let dbIcon = NSImageView() + let nameLabel = NSTextField(labelWithString: "") + let subtitleLabel = NSTextField(labelWithString: "") + let tagLabel = NSTextField(labelWithString: "") + let tagWrapper = NSView() + private let titleStack: NSStackView + + override init(frame frameRect: NSRect) { + titleStack = NSStackView() + super.init(frame: frameRect) + identifier = .connectionCell + setupViews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } + + private func setupViews() { + let iconSize = DesignConstants.IconSize.medium + + dbIcon.setContentHuggingPriority(.defaultHigh, for: .horizontal) + dbIcon.translatesAutoresizingMaskIntoConstraints = false + dbIcon.widthAnchor.constraint(equalToConstant: iconSize).isActive = true + dbIcon.heightAnchor.constraint(equalToConstant: iconSize).isActive = true + + nameLabel.font = NSFont.systemFont(ofSize: DesignConstants.FontSize.body, weight: .medium) + nameLabel.textColor = .labelColor + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + // Tag badge setup + tagLabel.font = NSFont.systemFont(ofSize: DesignConstants.FontSize.tiny) + tagLabel.drawsBackground = false + tagLabel.isBordered = false + tagLabel.isEditable = false + tagLabel.translatesAutoresizingMaskIntoConstraints = false + + tagWrapper.wantsLayer = true + tagWrapper.layer?.cornerRadius = DesignConstants.CornerRadius.small + tagWrapper.layer?.masksToBounds = true + tagWrapper.translatesAutoresizingMaskIntoConstraints = false + tagWrapper.setContentHuggingPriority(.defaultHigh, for: .horizontal) + tagWrapper.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + tagWrapper.addSubview(tagLabel) + let paddingH = DesignConstants.Spacing.xxs + let paddingV = DesignConstants.Spacing.xxxs + NSLayoutConstraint.activate([ + tagLabel.leadingAnchor.constraint(equalTo: tagWrapper.leadingAnchor, constant: paddingH), + tagLabel.trailingAnchor.constraint(equalTo: tagWrapper.trailingAnchor, constant: -paddingH), + tagLabel.topAnchor.constraint(equalTo: tagWrapper.topAnchor, constant: paddingV), + tagLabel.bottomAnchor.constraint(equalTo: tagWrapper.bottomAnchor, constant: -paddingV), + ]) + + titleStack.orientation = .horizontal + titleStack.alignment = .centerY + titleStack.spacing = DesignConstants.Spacing.xxs + 2 + titleStack.addArrangedSubview(nameLabel) + titleStack.addArrangedSubview(tagWrapper) + + subtitleLabel.font = NSFont.systemFont(ofSize: DesignConstants.FontSize.small) + subtitleLabel.textColor = .secondaryLabelColor + subtitleLabel.lineBreakMode = .byTruncatingTail + subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let textStack = NSStackView(views: [titleStack, subtitleLabel]) + textStack.orientation = .vertical + textStack.alignment = .leading + textStack.spacing = DesignConstants.Spacing.xxxs + textStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let container = NSStackView(views: [dbIcon, textStack]) + container.orientation = .horizontal + container.alignment = .centerY + container.spacing = DesignConstants.Spacing.sm + container.translatesAutoresizingMaskIntoConstraints = false + + addSubview(container) + NSLayoutConstraint.activate([ + container.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DesignConstants.Spacing.xxs), + container.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -DesignConstants.Spacing.xs), + container.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + textField = nameLabel + imageView = dbIcon + } + + func configure(connection: DatabaseConnection) { + // Icon + if let assetImage = NSImage(named: connection.type.iconName) { + let templateImage = assetImage.copy() as? NSImage ?? assetImage + templateImage.isTemplate = true + dbIcon.image = templateImage + dbIcon.contentTintColor = NSColor(connection.displayColor) + } + + // Name + nameLabel.stringValue = connection.name + + // Tag + if let tagId = connection.tagId, let tag = TagStorage.shared.tag(for: tagId) { + tagLabel.stringValue = tag.name + tagLabel.textColor = NSColor(tag.color.color) + tagWrapper.layer?.backgroundColor = NSColor(tag.color.color).withAlphaComponent(0.15).cgColor + tagWrapper.isHidden = false + } else { + tagWrapper.isHidden = true + } + + // Subtitle + if connection.sshConfig.enabled { + subtitleLabel.stringValue = "SSH : \(connection.sshConfig.username)@\(connection.sshConfig.host)" + } else if connection.host.isEmpty { + subtitleLabel.stringValue = connection.database.isEmpty ? connection.type.rawValue : connection.database + } else { + subtitleLabel.stringValue = connection.host + } + } +} + +// MARK: - ConnectionNSOutlineView + +/// Custom NSOutlineView subclass for context menus and keyboard handling +final class ConnectionNSOutlineView: NSOutlineView { + weak var coordinator: ConnectionOutlineView.Coordinator? + + override func drawBackground(inClipRect clipRect: NSRect) { + // Draw solid background instead of the translucent sourceList gray + backgroundColor.setFill() + clipRect.fill() + } + + override func menu(for event: NSEvent) -> NSMenu? { + let point = convert(event.locationInWindow, from: nil) + let clickedRow = row(at: point) + + if clickedRow >= 0 { + // Select the row under right-click + selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) + let item = self.item(atRow: clickedRow) + + if let outlineGroup = item as? OutlineGroup { + return coordinator?.contextMenu(for: outlineGroup) + } else if let outlineConn = item as? OutlineConnection { + return coordinator?.contextMenu(for: outlineConn) + } + } + + return coordinator?.emptySpaceContextMenu() + } + + override func keyDown(with event: NSEvent) { + // Return key on a connection triggers double-click action + if event.keyCode == 36 { + let row = selectedRow + if row >= 0, let outlineConn = item(atRow: row) as? OutlineConnection { + coordinator?.parent.onDoubleClickConnection?(outlineConn.connection) + return + } + // Return on a group toggles expand/collapse + if row >= 0, let outlineGroup = item(atRow: row) as? OutlineGroup { + coordinator?.parent.onToggleGroup?(outlineGroup.group.id) + return + } + } + super.keyDown(with: event) + } +} + +// MARK: - Coordinator + +extension ConnectionOutlineView { + final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionOutlineView.Coordinator") + + var parent: ConnectionOutlineView + weak var outlineView: ConnectionNSOutlineView? + var isSyncingSelection = false + var isDragging = false + private var draggedItemId: UUID? + + // Data model + var rootItems: [NSObject] = [] + var childrenMap: [UUID: [NSObject]] = [:] + private var allGroupItems: [UUID: OutlineGroup] = [:] + private var allConnectionItems: [UUID: OutlineConnection] = [:] + private var isSearchMode = false + + // Snapshot for change detection + private var lastSnapshotHash = 0 + + init(parent: ConnectionOutlineView) { + self.parent = parent + } + + // MARK: - Data Building + + func needsReload(groups: [ConnectionGroup], connections: [DatabaseConnection], searchText: String) -> Bool { + let hash = computeSnapshotHash(groups: groups, connections: connections, searchText: searchText) + return hash != lastSnapshotHash + } + + private func computeSnapshotHash(groups: [ConnectionGroup], connections: [DatabaseConnection], searchText: String) -> Int { + var hasher = Hasher() + hasher.combine(searchText) + for g in groups { + hasher.combine(g.id) + hasher.combine(g.sortOrder) + hasher.combine(g.parentGroupId) + hasher.combine(g.name) + hasher.combine(g.color) + } + for c in connections { + hasher.combine(c.id) + hasher.combine(c.sortOrder) + hasher.combine(c.groupId) + hasher.combine(c.name) + hasher.combine(c.host) + hasher.combine(c.tagId) + } + return hasher.finalize() + } + + func rebuildData(groups: [ConnectionGroup], connections: [DatabaseConnection], searchText: String) { + rootItems.removeAll() + childrenMap.removeAll() + allGroupItems.removeAll() + allConnectionItems.removeAll() + isSearchMode = !searchText.isEmpty + + lastSnapshotHash = computeSnapshotHash(groups: groups, connections: connections, searchText: searchText) + + if isSearchMode { + // Flat filtered list of connections only + let query = searchText.lowercased() + let filtered = connections.filter { conn in + conn.name.lowercased().contains(query) + || conn.host.lowercased().contains(query) + || conn.database.lowercased().contains(query) + } + .sorted { $0.sortOrder < $1.sortOrder } + + for conn in filtered { + let item = OutlineConnection(conn) + allConnectionItems[conn.id] = item + rootItems.append(item) + } + return + } + + // Build group items + for group in groups { + let item = OutlineGroup(group) + allGroupItems[group.id] = item + } + + // Build connection items + for conn in connections { + let item = OutlineConnection(conn) + allConnectionItems[conn.id] = item + } + + // Build children map for each group + for group in groups { + var children: [NSObject] = [] + + // Child groups sorted by sortOrder + let childGroups = groups + .filter { $0.parentGroupId == group.id } + .sorted { $0.sortOrder < $1.sortOrder } + for child in childGroups { + if let item = allGroupItems[child.id] { + children.append(item) + } + } + + // Connections in this group sorted by sortOrder + let groupConns = connections + .filter { $0.groupId == group.id } + .sorted { $0.sortOrder < $1.sortOrder } + for conn in groupConns { + if let item = allConnectionItems[conn.id] { + children.append(item) + } + } + + childrenMap[group.id] = children + } + + // Root items: root groups (parentGroupId == nil) sorted by sortOrder, + // then ungrouped connections (groupId == nil) sorted by sortOrder + let rootGroups = groups + .filter { $0.parentGroupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + for group in rootGroups { + if let item = allGroupItems[group.id] { + rootItems.append(item) + } + } + + let ungroupedConns = connections + .filter { $0.groupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + for conn in ungroupedConns { + if let item = allConnectionItems[conn.id] { + rootItems.append(item) + } + } + } + + func itemById(_ id: UUID) -> NSObject? { + if let item = allGroupItems[id] { + return item + } + return allConnectionItems[id] + } + + // MARK: - NSOutlineViewDataSource + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if item == nil { + return rootItems.count + } + if let outlineGroup = item as? OutlineGroup { + return childrenMap[outlineGroup.group.id]?.count ?? 0 + } + return 0 + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if item == nil { + return rootItems[index] + } + if let outlineGroup = item as? OutlineGroup, + let children = childrenMap[outlineGroup.group.id] + { + return children[index] + } + return NSObject() + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + if let outlineGroup = item as? OutlineGroup { + let children = childrenMap[outlineGroup.group.id] ?? [] + return !children.isEmpty + } + return false + } + + // MARK: - Drag Source + + func outlineView( + _ outlineView: NSOutlineView, + pasteboardWriterForItem item: Any + ) -> (any NSPasteboardWriting)? { + // Disable drag in search mode + guard !isSearchMode else { return nil } + + isDragging = true + + let pasteboardItem = NSPasteboardItem() + if let outlineGroup = item as? OutlineGroup { + draggedItemId = outlineGroup.group.id + pasteboardItem.setString(outlineGroup.group.id.uuidString, forType: .outlineItem) + } else if let outlineConn = item as? OutlineConnection { + draggedItemId = outlineConn.connection.id + pasteboardItem.setString(outlineConn.connection.id.uuidString, forType: .outlineItem) + } + return pasteboardItem + } + + func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + isDragging = false + draggedItemId = nil + } + + // MARK: - Drop Validation + + func outlineView( + _ outlineView: NSOutlineView, + validateDrop info: any NSDraggingInfo, + proposedItem item: Any?, + proposedChildIndex index: Int + ) -> NSDragOperation { + guard let draggedId = draggedItemId else { return [] } + + let isDraggingGroup = allGroupItems[draggedId] != nil + let isDraggingConnection = allConnectionItems[draggedId] != nil + + if isDraggingConnection { + return validateConnectionDrop( + outlineView: outlineView, + draggedId: draggedId, + proposedItem: item, + proposedChildIndex: index + ) + } + + if isDraggingGroup { + return validateGroupDrop( + outlineView: outlineView, + draggedId: draggedId, + proposedItem: item, + proposedChildIndex: index + ) + } + + return [] + } + + private func validateConnectionDrop( + outlineView: NSOutlineView, + draggedId: UUID, + proposedItem item: Any?, + proposedChildIndex index: Int + ) -> NSDragOperation { + if item == nil { + // Dropping at root level: ungroup + return .move + } + + if item is OutlineGroup { + // Dropping into/within a group + return .move + } + + if let outlineConn = item as? OutlineConnection { + // Proposed target is a connection: retarget to its parent group + let parentGroupId = outlineConn.connection.groupId + if let parentGroupId, let parentItem = allGroupItems[parentGroupId] { + let children = childrenMap[parentGroupId] ?? [] + let childIndex = children.firstIndex(where: { ($0 as? OutlineConnection)?.connection.id == outlineConn.connection.id }) + outlineView.setDropItem(parentItem, dropChildIndex: childIndex ?? children.count) + } else { + // Connection is at root: retarget to root + let rootIndex = rootItems.firstIndex(where: { ($0 as? OutlineConnection)?.connection.id == outlineConn.connection.id }) + outlineView.setDropItem(nil, dropChildIndex: rootIndex ?? rootItems.count) + } + return .move + } + + return [] + } + + private func validateGroupDrop( + outlineView: NSOutlineView, + draggedId: UUID, + proposedItem item: Any?, + proposedChildIndex index: Int + ) -> NSDragOperation { + // Prevent dropping a group into itself or its descendants + if let targetGroup = item as? OutlineGroup { + if targetGroup.group.id == draggedId { + return [] + } + if isDescendant(groupId: targetGroup.group.id, ofGroupId: draggedId) { + return [] + } + return .move + } + + // Root drop + if item == nil { + return .move + } + + // Dropping onto a connection: retarget to the connection's parent + if let outlineConn = item as? OutlineConnection { + let parentGroupId = outlineConn.connection.groupId + if let parentGroupId, let parentItem = allGroupItems[parentGroupId] { + if parentGroupId == draggedId || isDescendant(groupId: parentGroupId, ofGroupId: draggedId) { + return [] + } + let children = childrenMap[parentGroupId] ?? [] + let childIndex = children.firstIndex(where: { ($0 as? OutlineConnection)?.connection.id == outlineConn.connection.id }) + outlineView.setDropItem(parentItem, dropChildIndex: childIndex ?? children.count) + } else { + let rootIndex = rootItems.firstIndex(where: { ($0 as? OutlineConnection)?.connection.id == outlineConn.connection.id }) + outlineView.setDropItem(nil, dropChildIndex: rootIndex ?? rootItems.count) + } + return .move + } + + return [] + } + + /// Check if a group is a descendant of another group + private func isDescendant(groupId: UUID, ofGroupId ancestorId: UUID) -> Bool { + guard let children = childrenMap[ancestorId] else { return false } + for child in children { + guard let childGroup = child as? OutlineGroup else { continue } + if childGroup.group.id == groupId { + return true + } + if isDescendant(groupId: groupId, ofGroupId: childGroup.group.id) { + return true + } + } + return false + } + + // MARK: - Accept Drop + + func outlineView( + _ outlineView: NSOutlineView, + acceptDrop info: any NSDraggingInfo, + item: Any?, + childIndex index: Int + ) -> Bool { + guard let pasteboardItem = info.draggingPasteboard.pasteboardItems?.first, + let uuidString = pasteboardItem.string(forType: .outlineItem), + let draggedId = UUID(uuidString: uuidString) + else { + return false + } + + // Clear drag state before callbacks so the subsequent + // SwiftUI state update → updateNSView is not blocked + isDragging = false + draggedItemId = nil + + if let draggedConnItem = allConnectionItems[draggedId] { + return acceptConnectionDrop( + connection: draggedConnItem.connection, + targetItem: item, + childIndex: index + ) + } + + if let draggedGroupItem = allGroupItems[draggedId] { + return acceptGroupDrop( + group: draggedGroupItem.group, + targetItem: item, + childIndex: index + ) + } + + return false + } + + private func acceptConnectionDrop( + connection: DatabaseConnection, + targetItem: Any?, + childIndex: Int + ) -> Bool { + if let targetGroup = targetItem as? OutlineGroup { + let targetGroupId = targetGroup.group.id + + if childIndex == NSOutlineViewDropOnItemIndex { + // Dropped ON the group: move to end + var movedConn = connection + movedConn.groupId = targetGroupId + var siblings = parent.connections + .filter { $0.groupId == targetGroupId && $0.id != connection.id } + .sorted { $0.sortOrder < $1.sortOrder } + movedConn.sortOrder = (siblings.last?.sortOrder ?? -1) + 1 + siblings.append(movedConn) + parent.onReorderConnections?(siblings) + } else { + // Dropped at a specific index within the group + var siblings = parent.connections + .filter { $0.groupId == targetGroupId && $0.id != connection.id } + .sorted { $0.sortOrder < $1.sortOrder } + + let childGroups = childrenMap[targetGroupId]?.compactMap { $0 as? OutlineGroup } ?? [] + let connectionIndex = max(0, childIndex - childGroups.count) + + var movedConn = connection + movedConn.groupId = targetGroupId + siblings.insert(movedConn, at: min(connectionIndex, siblings.count)) + + for (order, var conn) in siblings.enumerated() { + conn.sortOrder = order + siblings[order] = conn + } + parent.onReorderConnections?(siblings) + } + return true + } + + if targetItem == nil { + if childIndex == NSOutlineViewDropOnItemIndex { + // Dropped ON root: just ungroup, append at end + var movedConn = connection + movedConn.groupId = nil + var rootConns = parent.connections + .filter { $0.groupId == nil && $0.id != connection.id } + .sorted { $0.sortOrder < $1.sortOrder } + movedConn.sortOrder = (rootConns.last?.sortOrder ?? -1) + 1 + rootConns.append(movedConn) + parent.onReorderConnections?(rootConns) + } else { + var rootConns = parent.connections + .filter { $0.groupId == nil && $0.id != connection.id } + .sorted { $0.sortOrder < $1.sortOrder } + + let rootGroupCount = rootItems.compactMap { $0 as? OutlineGroup }.count + let connectionIndex = max(0, childIndex - rootGroupCount) + + var movedConn = connection + movedConn.groupId = nil + rootConns.insert(movedConn, at: min(connectionIndex, rootConns.count)) + + for (order, var conn) in rootConns.enumerated() { + conn.sortOrder = order + rootConns[order] = conn + } + parent.onReorderConnections?(rootConns) + } + return true + } + + return false + } + + private func acceptGroupDrop( + group: ConnectionGroup, + targetItem: Any?, + childIndex: Int + ) -> Bool { + if let targetGroup = targetItem as? OutlineGroup { + let newParentId = targetGroup.group.id + + if childIndex == NSOutlineViewDropOnItemIndex { + // Dropped ON the group: move as last child + var movedGroup = group + movedGroup.parentGroupId = newParentId + var siblings = parent.groups + .filter { $0.parentGroupId == newParentId && $0.id != group.id } + .sorted { $0.sortOrder < $1.sortOrder } + movedGroup.sortOrder = (siblings.last?.sortOrder ?? -1) + 1 + siblings.append(movedGroup) + parent.onReorderGroups?(siblings) + } else { + var siblings = parent.groups + .filter { $0.parentGroupId == newParentId && $0.id != group.id } + .sorted { $0.sortOrder < $1.sortOrder } + + var movedGroup = group + movedGroup.parentGroupId = newParentId + siblings.insert(movedGroup, at: min(childIndex, siblings.count)) + + for (order, var g) in siblings.enumerated() { + g.sortOrder = order + siblings[order] = g + } + parent.onReorderGroups?(siblings) + } + return true + } + + if targetItem == nil { + if childIndex == NSOutlineViewDropOnItemIndex { + // Dropped ON root: move as last root group + var movedGroup = group + movedGroup.parentGroupId = nil + var rootGroupSiblings = parent.groups + .filter { $0.parentGroupId == nil && $0.id != group.id } + .sorted { $0.sortOrder < $1.sortOrder } + movedGroup.sortOrder = (rootGroupSiblings.last?.sortOrder ?? -1) + 1 + rootGroupSiblings.append(movedGroup) + parent.onReorderGroups?(rootGroupSiblings) + } else { + var rootGroupSiblings = parent.groups + .filter { $0.parentGroupId == nil && $0.id != group.id } + .sorted { $0.sortOrder < $1.sortOrder } + + var movedGroup = group + movedGroup.parentGroupId = nil + rootGroupSiblings.insert(movedGroup, at: min(childIndex, rootGroupSiblings.count)) + + for (order, var g) in rootGroupSiblings.enumerated() { + g.sortOrder = order + rootGroupSiblings[order] = g + } + parent.onReorderGroups?(rootGroupSiblings) + } + return true + } + + return false + } + + // MARK: - NSOutlineViewDelegate + + func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { + DesignConstants.RowHeight.comfortable + } + + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + if let outlineGroup = item as? OutlineGroup { + let cellView: GroupCellView + if let reused = outlineView.makeView(withIdentifier: .groupCell, owner: self) as? GroupCellView { + cellView = reused + } else { + cellView = GroupCellView() + } + cellView.configure(group: outlineGroup.group, connectionCount: totalConnectionCount(for: outlineGroup.group.id)) + return cellView + } + if let outlineConn = item as? OutlineConnection { + let cellView: ConnectionCellView + if let reused = outlineView.makeView(withIdentifier: .connectionCell, owner: self) as? ConnectionCellView { + cellView = reused + } else { + cellView = ConnectionCellView() + } + cellView.configure(connection: outlineConn.connection) + return cellView + } + return nil + } + + func outlineViewSelectionDidChange(_ notification: Notification) { + guard !isSyncingSelection else { return } + guard let outlineView = notification.object as? NSOutlineView else { return } + + let row = outlineView.selectedRow + guard row >= 0 else { + parent.onSelectionChanged?(nil) + return + } + + let item = outlineView.item(atRow: row) + if let outlineGroup = item as? OutlineGroup { + parent.onSelectionChanged?(outlineGroup.group.id) + } else if let outlineConn = item as? OutlineConnection { + parent.onSelectionChanged?(outlineConn.connection.id) + } else { + parent.onSelectionChanged?(nil) + } + } + + func outlineViewItemDidExpand(_ notification: Notification) { + guard let outlineGroup = notification.userInfo?["NSObject"] as? OutlineGroup else { return } + let groupId = outlineGroup.group.id + if !parent.expandedGroupIds.contains(groupId) { + parent.onToggleGroup?(groupId) + } + } + + func outlineViewItemDidCollapse(_ notification: Notification) { + guard let outlineGroup = notification.userInfo?["NSObject"] as? OutlineGroup else { return } + let groupId = outlineGroup.group.id + if parent.expandedGroupIds.contains(groupId) { + parent.onToggleGroup?(groupId) + } + } + + // MARK: - Double Click + + @objc func handleDoubleClick() { + guard let outlineView else { return } + let row = outlineView.clickedRow + guard row >= 0 else { return } + + let item = outlineView.item(atRow: row) + if let outlineConn = item as? OutlineConnection { + parent.onDoubleClickConnection?(outlineConn.connection) + } else if let outlineGroup = item as? OutlineGroup { + parent.onToggleGroup?(outlineGroup.group.id) + } + } + + private func totalConnectionCount(for groupId: UUID) -> Int { + let directConns = parent.connections.filter { $0.groupId == groupId }.count + let childGroupIds = parent.groups.filter { $0.parentGroupId == groupId }.map(\.id) + let nested = childGroupIds.reduce(0) { $0 + totalConnectionCount(for: $1) } + return directConns + nested + } + + // MARK: - Context Menus + + func contextMenu(for outlineGroup: OutlineGroup) -> NSMenu { + let menu = NSMenu() + let group = outlineGroup.group + + let newConnItem = NSMenuItem( + title: String(localized: "New Connection..."), + action: #selector(contextMenuNewConnection), + keyEquivalent: "" + ) + newConnItem.target = self + menu.addItem(newConnItem) + + let newSubgroupItem = NSMenuItem( + title: String(localized: "New Subgroup..."), + action: #selector(contextMenuNewSubgroup(_:)), + keyEquivalent: "" + ) + newSubgroupItem.target = self + newSubgroupItem.representedObject = group.id + menu.addItem(newSubgroupItem) + + menu.addItem(.separator()) + + let editItem = NSMenuItem( + title: String(localized: "Edit Group..."), + action: #selector(contextMenuEditGroup(_:)), + keyEquivalent: "" + ) + editItem.target = self + editItem.representedObject = group + menu.addItem(editItem) + + menu.addItem(.separator()) + + let deleteItem = NSMenuItem( + title: String(localized: "Delete Group"), + action: #selector(contextMenuDeleteGroup(_:)), + keyEquivalent: "" + ) + deleteItem.target = self + deleteItem.representedObject = group + deleteItem.setDestructiveStyle() + menu.addItem(deleteItem) + + return menu + } + + func contextMenu(for outlineConn: OutlineConnection) -> NSMenu { + let menu = NSMenu() + let connection = outlineConn.connection + + let connectItem = NSMenuItem( + title: String(localized: "Connect"), + action: #selector(contextMenuConnect(_:)), + keyEquivalent: "" + ) + connectItem.target = self + connectItem.representedObject = connection + menu.addItem(connectItem) + + menu.addItem(.separator()) + + let editItem = NSMenuItem( + title: String(localized: "Edit"), + action: #selector(contextMenuEditConnection(_:)), + keyEquivalent: "" + ) + editItem.target = self + editItem.representedObject = connection + menu.addItem(editItem) + + let duplicateItem = NSMenuItem( + title: String(localized: "Duplicate"), + action: #selector(contextMenuDuplicateConnection(_:)), + keyEquivalent: "" + ) + duplicateItem.target = self + duplicateItem.representedObject = connection + menu.addItem(duplicateItem) + + menu.addItem(.separator()) + + // Move to Group submenu + let moveMenu = NSMenu() + + let noneItem = NSMenuItem( + title: String(localized: "None"), + action: #selector(contextMenuMoveToGroup(_:)), + keyEquivalent: "" + ) + noneItem.target = self + noneItem.representedObject = ConnectionMoveInfo(connection: connection, targetGroupId: nil) + if connection.groupId == nil { + noneItem.state = .on + } + moveMenu.addItem(noneItem) + + moveMenu.addItem(.separator()) + + let rootGroups = parent.groups + .filter { $0.parentGroupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + + for group in rootGroups { + let groupItem = NSMenuItem( + title: group.name, + action: #selector(contextMenuMoveToGroup(_:)), + keyEquivalent: "" + ) + groupItem.target = self + groupItem.representedObject = ConnectionMoveInfo(connection: connection, targetGroupId: group.id) + groupItem.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil) + if connection.groupId == group.id { + groupItem.state = .on + } + moveMenu.addItem(groupItem) + } + + let moveItem = NSMenuItem(title: String(localized: "Move to Group"), action: nil, keyEquivalent: "") + moveItem.submenu = moveMenu + menu.addItem(moveItem) + + menu.addItem(.separator()) + + let deleteItem = NSMenuItem( + title: String(localized: "Delete"), + action: #selector(contextMenuDeleteConnection(_:)), + keyEquivalent: "" + ) + deleteItem.target = self + deleteItem.representedObject = connection + deleteItem.setDestructiveStyle() + menu.addItem(deleteItem) + + return menu + } + + func emptySpaceContextMenu() -> NSMenu { + let menu = NSMenu() + + let newConnItem = NSMenuItem( + title: String(localized: "New Connection..."), + action: #selector(contextMenuNewConnection), + keyEquivalent: "" + ) + newConnItem.target = self + menu.addItem(newConnItem) + + let newGroupItem = NSMenuItem( + title: String(localized: "New Group..."), + action: #selector(contextMenuNewGroupAtRoot), + keyEquivalent: "" + ) + newGroupItem.target = self + menu.addItem(newGroupItem) + + return menu + } + + // MARK: - Context Menu Actions + + @objc private func contextMenuNewConnection() { + parent.onNewConnection?() + } + + @objc private func contextMenuNewSubgroup(_ sender: NSMenuItem) { + guard let parentId = sender.representedObject as? UUID else { return } + parent.onNewGroup?(parentId) + } + + @objc private func contextMenuNewGroupAtRoot() { + parent.onNewGroup?(nil) + } + + @objc private func contextMenuEditGroup(_ sender: NSMenuItem) { + guard let group = sender.representedObject as? ConnectionGroup else { return } + parent.onEditGroup?(group) + } + + @objc private func contextMenuDeleteGroup(_ sender: NSMenuItem) { + guard let group = sender.representedObject as? ConnectionGroup else { return } + parent.onDeleteGroup?(group) + } + + @objc private func contextMenuConnect(_ sender: NSMenuItem) { + guard let connection = sender.representedObject as? DatabaseConnection else { return } + parent.onDoubleClickConnection?(connection) + } + + @objc private func contextMenuEditConnection(_ sender: NSMenuItem) { + guard let connection = sender.representedObject as? DatabaseConnection else { return } + parent.onEditConnection?(connection) + } + + @objc private func contextMenuDuplicateConnection(_ sender: NSMenuItem) { + guard let connection = sender.representedObject as? DatabaseConnection else { return } + parent.onDuplicateConnection?(connection) + } + + @objc private func contextMenuDeleteConnection(_ sender: NSMenuItem) { + guard let connection = sender.representedObject as? DatabaseConnection else { return } + parent.onDeleteConnection?(connection) + } + + @objc private func contextMenuMoveToGroup(_ sender: NSMenuItem) { + guard let moveInfo = sender.representedObject as? ConnectionMoveInfo else { return } + parent.onMoveConnectionToGroup?(moveInfo.connection, moveInfo.targetGroupId) + } + } +} + +// MARK: - ConnectionMoveInfo + +/// Helper to pass both connection and target group ID through NSMenuItem.representedObject +private final class ConnectionMoveInfo: NSObject { + let connection: DatabaseConnection + let targetGroupId: UUID? + + init(connection: DatabaseConnection, targetGroupId: UUID?) { + self.connection = connection + self.targetGroupId = targetGroupId + } +} + +// MARK: - NSMenuItem Destructive Style + +private extension NSMenuItem { + func setDestructiveStyle() { + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.systemRed, + ] + attributedTitle = NSAttributedString(string: title, attributes: attributes) + } +} diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index f21724c5..b3b663a7 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -19,13 +19,9 @@ struct WelcomeWindowView: View { @State private var connections: [DatabaseConnection] = [] @State private var searchText = "" - @State private var showNewConnectionSheet = false - @State private var showEditConnectionSheet = false - @State private var connectionToEdit: DatabaseConnection? @State private var connectionToDelete: DatabaseConnection? @State private var showDeleteConfirmation = false - @State private var hoveredConnectionId: UUID? - @State private var selectedConnectionId: UUID? // For keyboard navigation + @State private var selectedConnectionId: UUID? @State private var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() // Group state @@ -230,218 +226,88 @@ struct WelcomeWindowView: View { Divider() // Connection list - if filteredConnections.isEmpty { + if connections.isEmpty, groups.isEmpty { + emptyState + } else if !searchText.isEmpty, filteredConnections.isEmpty { emptyState } else { connectionList } } .frame(minWidth: 350) - .contentShape(Rectangle()) - .contextMenu { newConnectionContextMenu } - } - - @ViewBuilder - private var newConnectionContextMenu: some View { - Button(action: { openWindow(id: "connection-form") }) { - Label("New Connection...", systemImage: "plus") - } - Button(action: { - groupToEdit = nil - newGroupParentId = nil - showNewGroupSheet = true - }) { - Label("New Group...", systemImage: "folder.badge.plus") - } } - // MARK: - Connection List + // MARK: - Connection List (NSOutlineView) - /// Connection list with group hierarchy support. - /// When searching: flat filtered list. Otherwise: grouped with DisclosureGroups. private var connectionList: some View { - List(selection: $selectedConnectionId) { - if !searchText.isEmpty { - // Flat filtered list during search - ForEach(filteredConnections) { connection in - connectionRow(for: connection) - } - } else { - // Grouped hierarchical view - ForEach(rootGroups) { group in - groupSection(for: group) - } - .onMove { from, to in - moveRootGroups(from: from, to: to) - } - - // Ungrouped connections - ForEach(ungroupedConnections) { connection in - connectionRow(for: connection) - } - .onMove { from, to in - moveUngroupedConnections(from: from, to: to) - } - } - } - .listStyle(.inset) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 44) - .dropDestination(for: String.self) { items, _ in - handleDropOnGroup(items: items, targetGroupId: nil) - } - .onKeyPress(.return) { - if let id = selectedConnectionId, - let connection = connections.first(where: { $0.id == id }) - { + ConnectionOutlineView( + groups: groups, + connections: searchText.isEmpty ? connections : filteredConnections, + expandedGroupIds: expandedGroups, + selectedItemId: selectedConnectionId, + searchText: searchText, + onSelectionChanged: { id in + selectedConnectionId = id + }, + onDoubleClickConnection: { connection in connectToDatabase(connection) - } - return .handled - } - } - - // MARK: - Group Hierarchy - - private var rootGroups: [ConnectionGroup] { - groups.filter { $0.parentGroupId == nil } - .sorted { $0.sortOrder < $1.sortOrder } - } - - private var ungroupedConnections: [DatabaseConnection] { - connections.filter { $0.groupId == nil } - .sorted { $0.sortOrder < $1.sortOrder } - } - - private func childGroups(of parentId: UUID) -> [ConnectionGroup] { - groups.filter { $0.parentGroupId == parentId } - .sorted { $0.sortOrder < $1.sortOrder } - } - - private func connectionsInGroup(_ groupId: UUID) -> [DatabaseConnection] { - connections.filter { $0.groupId == groupId } - .sorted { $0.sortOrder < $1.sortOrder } - } - - private func totalConnectionCount(in group: ConnectionGroup) -> Int { - let direct = connections.filter { $0.groupId == group.id }.count - let children = childGroups(of: group.id) - let nested = children.reduce(0) { $0 + totalConnectionCount(in: $1) } - return direct + nested - } - - private func expandedBinding(_ groupId: UUID) -> Binding { - Binding( - get: { expandedGroups.contains(groupId) }, - set: { isExpanded in - if isExpanded { - expandedGroups.insert(groupId) - } else { - expandedGroups.remove(groupId) - } - groupStorage.saveExpandedGroupIds(expandedGroups) - } - ) - } - - private func groupSection(for group: ConnectionGroup) -> AnyView { - AnyView( - DisclosureGroup(isExpanded: expandedBinding(group.id)) { - ForEach(childGroups(of: group.id)) { child in - groupSection(for: child) - } - ForEach(connectionsInGroup(group.id)) { connection in - connectionRow(for: connection) + }, + onToggleGroup: { groupId in + toggleGroup(groupId) + }, + onMoveConnection: { connection, newGroupId in + moveConnectionToGroup(connection, groupId: newGroupId) + }, + onReorderConnections: { reorderedConns in + for conn in reorderedConns { + storage.updateConnection(conn) } - } label: { - groupRowLabel(group) - } - ) - } - - private func groupRowLabel(_ group: ConnectionGroup) -> some View { - HStack(spacing: 8) { - Image(systemName: "folder.fill") - .foregroundStyle(group.color.isDefault ? .secondary : group.color.color) - .font(.system(size: DesignConstants.FontSize.body)) - - Text(group.name) - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - .foregroundStyle(.primary) - - Text("\(totalConnectionCount(in: group))") - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.tertiary) - - Spacer() - } - .contentShape(Rectangle()) - .overlay( - DoubleClickView { - if expandedGroups.contains(group.id) { - expandedGroups.remove(group.id) - } else { - expandedGroups.insert(group.id) + loadConnections() + }, + onReorderGroups: { reorderedGroups in + for group in reorderedGroups { + groupStorage.updateGroup(group) } - groupStorage.saveExpandedGroupIds(expandedGroups) - } - ) - .dropDestination(for: String.self) { items, _ in - handleDropOnGroup(items: items, targetGroupId: group.id) - } - .contextMenu { - Button(action: { openWindow(id: "connection-form") }) { - Label("New Connection...", systemImage: "plus") - } - Button(action: { + loadConnections() + }, + onMoveGroup: { group, newParentId in + var updated = group + updated.parentGroupId = newParentId + groupStorage.updateGroup(updated) + loadConnections() + }, + onNewConnection: { + openWindow(id: "connection-form") + }, + onNewGroup: { parentId in groupToEdit = nil - newGroupParentId = group.id + newGroupParentId = parentId showNewGroupSheet = true - }) { - Label("New Subgroup...", systemImage: "folder.badge.plus") - } - Divider() - Button(action: { + }, + onEditGroup: { group in groupToEdit = group newGroupParentId = group.parentGroupId showNewGroupSheet = true - }) { - Label("Edit Group...", systemImage: "pencil") - } - Divider() - Button(role: .destructive) { + }, + onDeleteGroup: { group in groupToDelete = group showDeleteGroupConfirmation = true - } label: { - Label("Delete Group", systemImage: "trash") - } - } - } - - @ViewBuilder - private func connectionRow(for connection: DatabaseConnection) -> some View { - ConnectionRow( - connection: connection, - groups: groups, - onConnect: { connectToDatabase(connection) }, - onEdit: { + }, + onEditConnection: { connection in openWindow(id: "connection-form", value: connection.id as UUID?) focusConnectionFormWindow() }, - onDuplicate: { + onDuplicateConnection: { connection in duplicateConnection(connection) }, - onDelete: { + onDeleteConnection: { connection in connectionToDelete = connection showDeleteConfirmation = true }, - onMoveToGroup: { groupId in + onMoveConnectionToGroup: { connection, groupId in moveConnectionToGroup(connection, groupId: groupId) } ) - .tag(connection.id) - .listRowInsets(DesignConstants.swiftUIListRowInsets) - .listRowSeparator(.hidden) - .draggable(connection.id.uuidString) } // MARK: - Empty State @@ -571,199 +437,13 @@ struct WelcomeWindowView: View { loadConnections() } - private func moveUngroupedConnections(from: IndexSet, to: Int) { - var ungrouped = connections.filter { $0.groupId == nil } - .sorted { $0.sortOrder < $1.sortOrder } - ungrouped.move(fromOffsets: from, toOffset: to) - for (index, var conn) in ungrouped.enumerated() { - conn.sortOrder = index - storage.updateConnection(conn) - } - loadConnections() - } - - private func moveRootGroups(from: IndexSet, to: Int) { - var roots = groups.filter { $0.parentGroupId == nil } - .sorted { $0.sortOrder < $1.sortOrder } - roots.move(fromOffsets: from, toOffset: to) - for (index, var group) in roots.enumerated() { - group.sortOrder = index - groupStorage.updateGroup(group) - } - loadConnections() - } - - private func handleDropOnGroup(items: [String], targetGroupId: UUID?) -> Bool { - var changed = false - for item in items { - guard let uuid = UUID(uuidString: item) else { continue } - if var conn = connections.first(where: { $0.id == uuid }) { - guard conn.groupId != targetGroupId else { continue } - conn.groupId = targetGroupId - storage.updateConnection(conn) - changed = true - } - } - if changed { - loadConnections() - } - return changed - } -} - -// MARK: - ConnectionRow - -private struct ConnectionRow: View { - let connection: DatabaseConnection - var groups: [ConnectionGroup] = [] - var onConnect: (() -> Void)? - var onEdit: (() -> Void)? - var onDuplicate: (() -> Void)? - var onDelete: (() -> Void)? - var onMoveToGroup: ((UUID?) -> Void)? - - private var displayTag: ConnectionTag? { - guard let tagId = connection.tagId else { return nil } - return TagStorage.shared.tag(for: tagId) - } - - var body: some View { - HStack(spacing: 12) { - // Database type icon - Image(connection.type.iconName) - .renderingMode(.template) - .font(.system(size: DesignConstants.IconSize.medium)) - .foregroundStyle(connection.displayColor) - .frame( - width: DesignConstants.IconSize.medium, height: DesignConstants.IconSize.medium) - - // Connection info - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(connection.name) - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - .foregroundStyle(.primary) - - // Tag (single) - if let tag = displayTag { - Text(tag.name) - .font(.system(size: DesignConstants.FontSize.tiny)) - .foregroundStyle(tag.color.color) - .padding(.horizontal, DesignConstants.Spacing.xxs) - .padding(.vertical, DesignConstants.Spacing.xxxs) - .background( - RoundedRectangle(cornerRadius: 4).fill( - tag.color.color.opacity(0.15))) - } - } - - Text(connectionSubtitle) - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - } - .padding(.vertical, DesignConstants.Spacing.xxs) - .contentShape(Rectangle()) - .overlay( - DoubleClickView { onConnect?() } - ) - .contextMenu { - if let onConnect = onConnect { - Button(action: onConnect) { - Label("Connect", systemImage: "play.fill") - } - Divider() - } - - if let onEdit = onEdit { - Button(action: onEdit) { - Label("Edit", systemImage: "pencil") - } - } - - if let onDuplicate = onDuplicate { - Button(action: onDuplicate) { - Label("Duplicate", systemImage: "doc.on.doc") - } - } - - if !groups.isEmpty, let onMoveToGroup = onMoveToGroup { - Divider() - Menu("Move to Group") { - Button { - onMoveToGroup(nil) - } label: { - HStack { - Text("None") - if connection.groupId == nil { - Spacer() - Image(systemName: "checkmark") - } - } - } - Divider() - ForEach(groups.filter { $0.parentGroupId == nil }.sorted { $0.sortOrder < $1.sortOrder }) { group in - Button { - onMoveToGroup(group.id) - } label: { - HStack { - Image(systemName: "folder.fill") - Text(group.name) - if connection.groupId == group.id { - Spacer() - Image(systemName: "checkmark") - } - } - } - } - } - } - - if let onDelete = onDelete { - Divider() - Button(role: .destructive, action: onDelete) { - Label("Delete", systemImage: "trash") - } - } - } - } - - private var connectionSubtitle: String { - if connection.sshConfig.enabled { - return "SSH : \(connection.sshConfig.username)@\(connection.sshConfig.host)" - } - if connection.host.isEmpty { - return connection.database.isEmpty ? connection.type.rawValue : connection.database - } - return connection.host - } -} - -// MARK: - EnvironmentBadge - -private struct EnvironmentBadge: View { - let connection: DatabaseConnection - - private var environment: ConnectionEnvironment { - if connection.sshConfig.enabled { - return .ssh - } - if connection.host.contains("prod") || connection.name.lowercased().contains("prod") { - return .production - } - if connection.host.contains("staging") || connection.name.lowercased().contains("staging") { - return .staging + private func toggleGroup(_ groupId: UUID) { + if expandedGroups.contains(groupId) { + expandedGroups.remove(groupId) + } else { + expandedGroups.insert(groupId) } - return .local - } - - var body: some View { - Text("(\(environment.rawValue.lowercased()))") - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(environment.badgeColor) + groupStorage.saveExpandedGroupIds(expandedGroups) } } @@ -808,51 +488,6 @@ private struct KeyboardHint: View { } } -// MARK: - ConnectionEnvironment Extension - -private extension ConnectionEnvironment { - var badgeColor: Color { - switch self { - case .local: - return Color(nsColor: .systemGreen) - case .ssh: - return Color(nsColor: .systemBlue) - case .staging: - return Color(nsColor: .systemOrange) - case .production: - return Color(nsColor: .systemRed) - } - } -} - -// MARK: - DoubleClickView - -private struct DoubleClickView: NSViewRepresentable { - let onDoubleClick: () -> Void - - func makeNSView(context: Context) -> NSView { - let view = PassThroughDoubleClickView() - view.onDoubleClick = onDoubleClick - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - (nsView as? PassThroughDoubleClickView)?.onDoubleClick = onDoubleClick - } -} - -private class PassThroughDoubleClickView: NSView { - var onDoubleClick: (() -> Void)? - - override func mouseDown(with event: NSEvent) { - if event.clickCount >= 2, event.clickCount % 2 == 0 { - onDoubleClick?() - } - // Always forward to next responder for List selection - super.mouseDown(with: event) - } -} - // MARK: - Preview #Preview("Welcome Window") { From e00e14e0401006143164fccca8d5ffe17e7f7304 Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:30:42 +0700 Subject: [PATCH 03/16] chore: update localization strings --- TablePro/Resources/Localizable.xcstrings | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 4b483457..2bddf687 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -63,6 +63,7 @@ } }, "(%@)" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { From 7aab5b9c2ac7274ad3b2c3235322adb533cfd1de Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:20:55 +0700 Subject: [PATCH 04/16] fix --- TablePro/Core/Storage/ConnectionStorage.swift | 9 ++------- TablePro/Models/DatabaseConnection.swift | 3 --- TablePro/Resources/Localizable.xcstrings | 16 ++++------------ .../Views/Connection/ConnectionFormView.swift | 8 ++++---- .../WelcomeWindow/ConnectionOutlineView.swift | 6 ++---- 5 files changed, 12 insertions(+), 30 deletions(-) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index dad76262..a653ce72 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -358,8 +358,7 @@ private struct StoredConnection: Codable { // AI policy let aiPolicy: String? - // Group - let groupId: String? + // Sort order let sortOrder: Int init(from connection: DatabaseConnection) { @@ -397,8 +396,7 @@ private struct StoredConnection: Codable { // AI policy self.aiPolicy = connection.aiPolicy?.rawValue - // Group - self.groupId = connection.groupId?.uuidString + // Sort order self.sortOrder = connection.sortOrder } @@ -436,7 +434,6 @@ private struct StoredConnection: Codable { groupId = try container.decodeIfPresent(String.self, forKey: .groupId) isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy) - groupId = try container.decodeIfPresent(String.self, forKey: .groupId) sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 } @@ -462,7 +459,6 @@ private struct StoredConnection: Codable { let parsedTagId = tagId.flatMap { UUID(uuidString: $0) } let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) } let parsedAIPolicy = aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) } - let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) } return DatabaseConnection( id: id, @@ -479,7 +475,6 @@ private struct StoredConnection: Codable { groupId: parsedGroupId, isReadOnly: isReadOnly, aiPolicy: parsedAIPolicy, - groupId: parsedGroupId, sortOrder: sortOrder ) } diff --git a/TablePro/Models/DatabaseConnection.swift b/TablePro/Models/DatabaseConnection.swift index 20692495..b8be1d70 100644 --- a/TablePro/Models/DatabaseConnection.swift +++ b/TablePro/Models/DatabaseConnection.swift @@ -253,7 +253,6 @@ struct DatabaseConnection: Identifiable, Hashable { var aiPolicy: AIConnectionPolicy? var mongoReadPreference: String? var mongoWriteConcern: String? - var groupId: UUID? var sortOrder: Int init( @@ -273,7 +272,6 @@ struct DatabaseConnection: Identifiable, Hashable { aiPolicy: AIConnectionPolicy? = nil, mongoReadPreference: String? = nil, mongoWriteConcern: String? = nil, - groupId: UUID? = nil, sortOrder: Int = 0 ) { self.id = id @@ -292,7 +290,6 @@ struct DatabaseConnection: Identifiable, Hashable { self.aiPolicy = aiPolicy self.mongoReadPreference = mongoReadPreference self.mongoWriteConcern = mongoWriteConcern - self.groupId = groupId self.sortOrder = sortOrder } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 2bddf687..d7f133e0 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1483,9 +1483,6 @@ } } } - }, - "Change Color" : { - }, "Change File" : { "localizations" : { @@ -2414,6 +2411,10 @@ } } }, + "Create New Group" : { + "comment" : "A label for creating a new group.", + "isCommentAutoGenerated" : true + }, "Create New Group..." : { "comment" : "A menu item that allows users to create a new connection group.", "isCommentAutoGenerated" : true @@ -3419,9 +3420,6 @@ } } } - }, - "Enter a new name for the group." : { - }, "Enter database name" : { "localizations" : { @@ -6945,12 +6943,6 @@ } } } - }, - "Rename" : { - - }, - "Rename Group" : { - }, "Reopen Last Session" : { "localizations" : { diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index c9213043..8cdac98d 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -636,11 +636,11 @@ struct ConnectionFormView: View { sslConfig: sslConfig, color: connectionColor, tagId: selectedTagId, + groupId: selectedGroupId, isReadOnly: isReadOnly, aiPolicy: aiPolicy, mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, - mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, - groupId: selectedGroupId + mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern ) // Save passwords to Keychain @@ -735,9 +735,9 @@ struct ConnectionFormView: View { sslConfig: sslConfig, color: connectionColor, tagId: selectedTagId, + groupId: selectedGroupId, mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, - mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, - groupId: selectedGroupId + mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern ) Task { diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift index 5819efdb..64e6d36d 100644 --- a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -83,7 +83,7 @@ struct ConnectionOutlineView: NSViewRepresentable { outlineView.rowSizeStyle = .default outlineView.usesAutomaticRowHeights = false outlineView.indentationPerLevel = 20 - outlineView.backgroundColor = .controlBackgroundColor + outlineView.backgroundColor = .clear outlineView.registerForDraggedTypes([.outlineItem]) outlineView.setDraggingSourceOperationMask(.move, forLocal: true) @@ -396,9 +396,7 @@ final class ConnectionNSOutlineView: NSOutlineView { weak var coordinator: ConnectionOutlineView.Coordinator? override func drawBackground(inClipRect clipRect: NSRect) { - // Draw solid background instead of the translucent sourceList gray - backgroundColor.setFill() - clipRect.fill() + // Sip the translucent gray; SwiftUI parent background shows through } override func menu(for event: NSEvent) -> NSMenu? { From 1af3852335251e0b9c1e384e85d93b6917304223 Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:20:07 +0700 Subject: [PATCH 05/16] feat: support nested group selection in form sheet and context menus --- TablePro/Resources/Localizable.xcstrings | 19 +- .../Connection/ConnectionGroupEditor.swift | 94 +++++---- .../Connection/ConnectionGroupFormSheet.swift | 184 ++++++++++++++++-- .../WelcomeWindow/ConnectionOutlineView.swift | 70 +++++-- TablePro/Views/WelcomeWindowView.swift | 34 ++-- 5 files changed, 312 insertions(+), 89 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 21adca91..2b226c97 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -290,18 +290,6 @@ } } }, - "%@%@" : { - "comment" : "A button label that includes a dot representing the group's color, followed by the group's name", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@%2$@" - } - } - } - }, "%@ms" : { "localizations" : { "vi" : { @@ -2275,9 +2263,6 @@ } } } - }, - "Copy as URL" : { - }, "Copy Cell Value" : { "localizations" : { @@ -6248,6 +6233,10 @@ } } }, + "Parent Group" : { + "comment" : "A label for selecting a parent group in the connection group form sheet.", + "isCommentAutoGenerated" : true + }, "Passphrase" : { "localizations" : { "vi" : { diff --git a/TablePro/Views/Connection/ConnectionGroupEditor.swift b/TablePro/Views/Connection/ConnectionGroupEditor.swift index abb80a2f..06a7caa5 100644 --- a/TablePro/Views/Connection/ConnectionGroupEditor.swift +++ b/TablePro/Views/Connection/ConnectionGroupEditor.swift @@ -18,6 +18,12 @@ struct ConnectionGroupEditor: View { return groupStorage.group(for: id) } + private func children(of parentId: UUID?) -> [ConnectionGroup] { + allGroups + .filter { $0.parentGroupId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } + } + var body: some View { Menu { Button { @@ -32,21 +38,13 @@ struct ConnectionGroupEditor: View { } } - Divider() + let rootGroups = children(of: nil) + if !rootGroups.isEmpty { + Divider() + } - ForEach(sortedGroupsFlat(), id: \.group.id) { item in - Button { - selectedGroupId = item.group.id - } label: { - HStack { - Image(nsImage: colorDot(item.group.color.color)) - Text("\(item.prefix)\(item.group.name)") - if selectedGroupId == item.group.id { - Spacer() - Image(systemName: "checkmark") - } - } - } + ForEach(rootGroups) { group in + groupMenuItem(group) } Divider() @@ -57,7 +55,7 @@ struct ConnectionGroupEditor: View { Label("Create New Group...", systemImage: "plus.circle") } - if allGroups.contains(where: { _ in true }) { + if !allGroups.isEmpty { Divider() Menu("Manage Groups") { @@ -98,28 +96,58 @@ struct ConnectionGroupEditor: View { // MARK: - Helpers - private struct FlatGroupItem { - let group: ConnectionGroup - let prefix: String - } + private func groupMenuItem(_ group: ConnectionGroup) -> AnyView { + let subgroups = children(of: group.id) + if subgroups.isEmpty { + return AnyView( + Button { + selectedGroupId = group.id + } label: { + HStack { + Image(nsImage: colorDot(group.color.color)) + Text(group.name) + if selectedGroupId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + ) + } else { + return AnyView( + Menu { + Button { + selectedGroupId = group.id + } label: { + HStack { + Image(nsImage: colorDot(group.color.color)) + Text(group.name) + if selectedGroupId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } - private func sortedGroupsFlat() -> [FlatGroupItem] { - var result: [FlatGroupItem] = [] - func walk(_ parentId: UUID?, depth: Int) { - let children = allGroups - .filter { $0.parentGroupId == parentId } - .sorted { $0.sortOrder < $1.sortOrder } - for child in children { - let prefix = String(repeating: " ", count: depth) - result.append(FlatGroupItem(group: child, prefix: prefix)) - walk(child.id, depth: depth + 1) - } + Divider() + + ForEach(subgroups) { child in + groupMenuItem(child) + } + } label: { + HStack { + Image(nsImage: colorDot(group.color.color)) + Text(group.name) + if selectedGroupId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + ) } - walk(nil, depth: 0) - return result } - /// Create a colored circle NSImage for use in menu items private func colorDot(_ color: Color) -> NSImage { let size = NSSize(width: 10, height: 10) let image = NSImage(size: size, flipped: false) { rect in diff --git a/TablePro/Views/Connection/ConnectionGroupFormSheet.swift b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift index 5d05688b..970ecbd9 100644 --- a/TablePro/Views/Connection/ConnectionGroupFormSheet.swift +++ b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift @@ -15,6 +15,9 @@ struct ConnectionGroupFormSheet: View { @State private var name: String = "" @State private var color: ConnectionColor = .blue + @State private var selectedParentId: UUID? + + private let groupStorage = GroupStorage.shared init( group: ConnectionGroup? = nil, @@ -26,26 +29,45 @@ struct ConnectionGroupFormSheet: View { self.onSave = onSave } + /// All groups excluding self and descendants when editing + private var availableGroups: [ConnectionGroup] { + let allGroups = groupStorage.loadGroups() + guard let editingGroup = group else { return allGroups } + let excludedIds = collectDescendantIds(of: editingGroup.id, in: allGroups) + .union([editingGroup.id]) + return allGroups.filter { !excludedIds.contains($0.id) } + } + var body: some View { - VStack(spacing: 16) { + VStack(spacing: 0) { Text(group == nil ? String(localized: "New Group") : String(localized: "Edit Group")) .font(.headline) + .padding(.top, 20) - TextField(String(localized: "Group name"), text: $name) - .textFieldStyle(.roundedBorder) - .frame(width: 200) + Form { + Section { + TextField(String(localized: "Name"), text: $name, prompt: Text("Group name")) - VStack(alignment: .leading, spacing: 6) { - Text("Color") - .font(.caption) - .foregroundStyle(.secondary) - GroupColorPicker(selectedColor: $color) + LabeledContent(String(localized: "Color")) { + GroupColorPicker(selectedColor: $color) + } + + LabeledContent(String(localized: "Parent Group")) { + ParentGroupPicker( + selectedParentId: $selectedParentId, + allGroups: availableGroups + ) + } + } } + .formStyle(.grouped) + .scrollContentBackground(.hidden) HStack { Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) Button(group == nil ? String(localized: "Create") : String(localized: "Save")) { save() @@ -54,13 +76,16 @@ struct ConnectionGroupFormSheet: View { .buttonStyle(.borderedProminent) .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) } + .padding(.bottom, 20) } - .padding(20) - .frame(width: 300) + .frame(width: 360) .onAppear { if let group { name = group.name color = group.color + selectedParentId = group.parentGroupId + } else { + selectedParentId = parentGroupId } } .onExitCommand { @@ -75,19 +100,152 @@ struct ConnectionGroupFormSheet: View { if var existing = group { existing.name = trimmedName existing.color = color + existing.parentGroupId = selectedParentId onSave?(existing) } else { - let sortOrder = GroupStorage.shared.nextSortOrder(parentId: parentGroupId) + let sortOrder = groupStorage.nextSortOrder(parentId: selectedParentId) let newGroup = ConnectionGroup( name: trimmedName, color: color, - parentGroupId: parentGroupId, + parentGroupId: selectedParentId, sortOrder: sortOrder ) onSave?(newGroup) } dismiss() } + + private func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set { + var result = Set() + let children = groups.filter { $0.parentGroupId == groupId } + for child in children { + result.insert(child.id) + result.formUnion(collectDescendantIds(of: child.id, in: groups)) + } + return result + } +} + +// MARK: - Parent Group Picker + +/// Menu-based group picker with nested submenus for child groups +private struct ParentGroupPicker: View { + @Binding var selectedParentId: UUID? + let allGroups: [ConnectionGroup] + + private var selectedGroup: ConnectionGroup? { + guard let id = selectedParentId else { return nil } + return allGroups.first { $0.id == id } + } + + private func children(of parentId: UUID?) -> [ConnectionGroup] { + allGroups + .filter { $0.parentGroupId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } + } + + var body: some View { + Menu { + Button { + selectedParentId = nil + } label: { + HStack { + Text("None") + if selectedParentId == nil { + Spacer() + Image(systemName: "checkmark") + } + } + } + + let rootGroups = children(of: nil) + if !rootGroups.isEmpty { + Divider() + } + + ForEach(rootGroups) { group in + groupMenuItem(group) + } + } label: { + HStack(spacing: 6) { + if let group = selectedGroup { + Image(systemName: "folder.fill") + .foregroundStyle(group.color.color) + .font(.system(size: 10)) + Text(group.name) + .foregroundStyle(.primary) + } else { + Text("None") + .foregroundStyle(.secondary) + } + } + } + .menuStyle(.borderlessButton) + .fixedSize() + } + + private func groupMenuItem(_ group: ConnectionGroup) -> AnyView { + let subgroups = children(of: group.id) + if subgroups.isEmpty { + return AnyView( + Button { + selectedParentId = group.id + } label: { + HStack { + Image(nsImage: colorDot(group.color.color)) + Text(group.name) + if selectedParentId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + ) + } else { + return AnyView( + Menu { + Button { + selectedParentId = group.id + } label: { + HStack { + Image(nsImage: colorDot(group.color.color)) + Text(group.name) + if selectedParentId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(subgroups) { child in + groupMenuItem(child) + } + } label: { + HStack { + Image(nsImage: colorDot(group.color.color)) + Text(group.name) + if selectedParentId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + ) + } + } + + private func colorDot(_ color: Color) -> NSImage { + let size = NSSize(width: 10, height: 10) + let image = NSImage(size: size, flipped: false) { rect in + NSColor(color).setFill() + NSBezierPath(ovalIn: rect).fill() + return true + } + image.isTemplate = false + return image + } } // MARK: - Group Color Picker diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift index 64e6d36d..ebb0b833 100644 --- a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -1144,18 +1144,7 @@ extension ConnectionOutlineView { .sorted { $0.sortOrder < $1.sortOrder } for group in rootGroups { - let groupItem = NSMenuItem( - title: group.name, - action: #selector(contextMenuMoveToGroup(_:)), - keyEquivalent: "" - ) - groupItem.target = self - groupItem.representedObject = ConnectionMoveInfo(connection: connection, targetGroupId: group.id) - groupItem.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil) - if connection.groupId == group.id { - groupItem.state = .on - } - moveMenu.addItem(groupItem) + moveMenu.addItem(moveToGroupMenuItem(for: connection, group: group)) } let moveItem = NSMenuItem(title: String(localized: "Move to Group"), action: nil, keyEquivalent: "") @@ -1244,6 +1233,63 @@ extension ConnectionOutlineView { parent.onDeleteConnection?(connection) } + /// Build an NSMenuItem for a group — if it has children, wrap in a submenu + private func moveToGroupMenuItem( + for connection: DatabaseConnection, + group: ConnectionGroup + ) -> NSMenuItem { + let childGroups = parent.groups + .filter { $0.parentGroupId == group.id } + .sorted { $0.sortOrder < $1.sortOrder } + + let folderImage = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(paletteColors: [ + group.color.isDefault ? .secondaryLabelColor : NSColor(group.color.color), + ])) + + if childGroups.isEmpty { + let item = NSMenuItem( + title: group.name, + action: #selector(contextMenuMoveToGroup(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = ConnectionMoveInfo(connection: connection, targetGroupId: group.id) + item.image = folderImage + if connection.groupId == group.id { + item.state = .on + } + return item + } else { + let submenu = NSMenu() + + // First item: select this group itself + let selfItem = NSMenuItem( + title: group.name, + action: #selector(contextMenuMoveToGroup(_:)), + keyEquivalent: "" + ) + selfItem.target = self + selfItem.representedObject = ConnectionMoveInfo(connection: connection, targetGroupId: group.id) + selfItem.image = folderImage + if connection.groupId == group.id { + selfItem.state = .on + } + submenu.addItem(selfItem) + + submenu.addItem(.separator()) + + for child in childGroups { + submenu.addItem(moveToGroupMenuItem(for: connection, group: child)) + } + + let item = NSMenuItem(title: group.name, action: nil, keyEquivalent: "") + item.submenu = submenu + item.image = folderImage + return item + } + } + @objc private func contextMenuMoveToGroup(_ sender: NSMenuItem) { guard let moveInfo = sender.representedObject as? ConnectionMoveInfo else { return } parent.onMoveConnectionToGroup?(moveInfo.connection, moveInfo.targetGroupId) diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index 8a40e8db..b86badff 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -16,7 +16,7 @@ struct WelcomeWindowView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "WelcomeWindowView") private let storage = ConnectionStorage.shared private let groupStorage = GroupStorage.shared - @ObservedObject private var dbManager = DatabaseManager.shared + private let dbManager = DatabaseManager.shared @State private var connections: [DatabaseConnection] = [] @State private var searchText = "" @@ -28,11 +28,9 @@ struct WelcomeWindowView: View { // Group state @State private var groups: [ConnectionGroup] = [] @State private var expandedGroups: Set = [] - @State private var showNewGroupSheet = false - @State private var groupToEdit: ConnectionGroup? + @State private var groupFormContext: GroupFormContext? @State private var groupToDelete: ConnectionGroup? @State private var showDeleteGroupConfirmation = false - @State private var newGroupParentId: UUID? @Environment(\.openWindow) private var openWindow @@ -97,20 +95,19 @@ struct WelcomeWindowView: View { } message: { group in Text("Are you sure you want to delete \"\(group.name)\"? Connections will be ungrouped.") } - .sheet(isPresented: $showNewGroupSheet) { + .sheet(item: $groupFormContext) { context in ConnectionGroupFormSheet( - group: groupToEdit, - parentGroupId: newGroupParentId + group: context.group, + parentGroupId: context.parentGroupId ) { group in - if groupToEdit != nil { + if context.group != nil { groupStorage.updateGroup(group) } else { groupStorage.addGroup(group) expandedGroups.insert(group.id) groupStorage.saveExpandedGroupIds(expandedGroups) } - groupToEdit = nil - newGroupParentId = nil + groupFormContext = nil loadConnections() } } @@ -280,14 +277,10 @@ struct WelcomeWindowView: View { openWindow(id: "connection-form") }, onNewGroup: { parentId in - groupToEdit = nil - newGroupParentId = parentId - showNewGroupSheet = true + groupFormContext = GroupFormContext(group: nil, parentGroupId: parentId) }, onEditGroup: { group in - groupToEdit = group - newGroupParentId = group.parentGroupId - showNewGroupSheet = true + groupFormContext = GroupFormContext(group: group, parentGroupId: group.parentGroupId) }, onDeleteGroup: { group in groupToDelete = group @@ -447,6 +440,15 @@ struct WelcomeWindowView: View { } } +// MARK: - GroupFormContext + +/// Identifiable wrapper so `.sheet(item:)` creates fresh content with correct values +private struct GroupFormContext: Identifiable { + let id = UUID() + let group: ConnectionGroup? + let parentGroupId: UUID? +} + // MARK: - WelcomeButtonStyle private struct WelcomeButtonStyle: ButtonStyle { From f7ef8ce0dcd586317aabb01db575167733348a4b Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:26:08 +0700 Subject: [PATCH 06/16] docs: add connection groups to changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9119d15f..b68f7428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Connection groups: organize connections into named, color-coded folders with support for nested subgroups, drag-and-drop reordering, expand/collapse state persistence, and context menus for group and connection management + ## [0.13.0] - 2026-03-04 ### Added From 2fd698b28aafb8d0fce3e7c5243c37b2058cc6bb Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Thu, 5 Mar 2026 02:02:25 +0700 Subject: [PATCH 07/16] refactor: rename ConnectionGroupEditor to ConnectionGroupPicker, fix search filter and keychain threading --- TablePro/Resources/Localizable.xcstrings | 8 +- .../Views/Connection/ConnectionFormView.swift | 2 +- .../Connection/ConnectionGroupEditor.swift | 169 -------------- .../Connection/ConnectionGroupPicker.swift | 213 ++++++++---------- .../WelcomeWindow/ConnectionOutlineView.swift | 28 +++ TablePro/Views/WelcomeWindowView.swift | 30 +++ 6 files changed, 156 insertions(+), 294 deletions(-) delete mode 100644 TablePro/Views/Connection/ConnectionGroupEditor.swift diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 2b226c97..728c3399 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -2264,6 +2264,10 @@ } } }, + "Copy as URL" : { + "comment" : "Title of a menu item that copies a connection URL to the clipboard.", + "isCommentAutoGenerated" : true + }, "Copy Cell Value" : { "localizations" : { "vi" : { @@ -2408,10 +2412,6 @@ } } }, - "Create New Group" : { - "comment" : "A label for creating a new group.", - "isCommentAutoGenerated" : true - }, "Create New Group..." : { "comment" : "A menu item that allows users to create a new connection group.", "isCommentAutoGenerated" : true diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 9b85d970..f7131990 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -228,7 +228,7 @@ struct ConnectionFormView: View { ConnectionTagEditor(selectedTagId: $selectedTagId) } LabeledContent(String(localized: "Group")) { - ConnectionGroupEditor(selectedGroupId: $selectedGroupId) + ConnectionGroupPicker(selectedGroupId: $selectedGroupId) } Toggle(String(localized: "Read-Only"), isOn: $isReadOnly) .help("Prevent write operations (INSERT, UPDATE, DELETE, DROP, etc.)") diff --git a/TablePro/Views/Connection/ConnectionGroupEditor.swift b/TablePro/Views/Connection/ConnectionGroupEditor.swift deleted file mode 100644 index 06a7caa5..00000000 --- a/TablePro/Views/Connection/ConnectionGroupEditor.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// ConnectionGroupEditor.swift -// TablePro -// - -import SwiftUI - -/// Group selection dropdown for the connection form -struct ConnectionGroupEditor: View { - @Binding var selectedGroupId: UUID? - @State private var allGroups: [ConnectionGroup] = [] - @State private var showingCreateSheet = false - - private let groupStorage = GroupStorage.shared - - private var selectedGroup: ConnectionGroup? { - guard let id = selectedGroupId else { return nil } - return groupStorage.group(for: id) - } - - private func children(of parentId: UUID?) -> [ConnectionGroup] { - allGroups - .filter { $0.parentGroupId == parentId } - .sorted { $0.sortOrder < $1.sortOrder } - } - - var body: some View { - Menu { - Button { - selectedGroupId = nil - } label: { - HStack { - Text("None") - if selectedGroupId == nil { - Spacer() - Image(systemName: "checkmark") - } - } - } - - let rootGroups = children(of: nil) - if !rootGroups.isEmpty { - Divider() - } - - ForEach(rootGroups) { group in - groupMenuItem(group) - } - - Divider() - - Button { - showingCreateSheet = true - } label: { - Label("Create New Group...", systemImage: "plus.circle") - } - - if !allGroups.isEmpty { - Divider() - - Menu("Manage Groups") { - ForEach(allGroups) { group in - Button(role: .destructive) { - deleteGroup(group) - } label: { - Label("Delete \"\(group.name)\"", systemImage: "trash") - } - } - } - } - } label: { - HStack(spacing: 6) { - if let group = selectedGroup { - Image(systemName: "folder.fill") - .foregroundStyle(group.color.color) - .font(.system(size: 10)) - Text(group.name) - .foregroundStyle(.primary) - } else { - Text("None") - .foregroundStyle(.secondary) - } - } - } - .menuStyle(.borderlessButton) - .fixedSize() - .task { allGroups = groupStorage.loadGroups() } - .sheet(isPresented: $showingCreateSheet) { - ConnectionGroupFormSheet { newGroup in - groupStorage.addGroup(newGroup) - selectedGroupId = newGroup.id - allGroups = groupStorage.loadGroups() - } - } - } - - // MARK: - Helpers - - private func groupMenuItem(_ group: ConnectionGroup) -> AnyView { - let subgroups = children(of: group.id) - if subgroups.isEmpty { - return AnyView( - Button { - selectedGroupId = group.id - } label: { - HStack { - Image(nsImage: colorDot(group.color.color)) - Text(group.name) - if selectedGroupId == group.id { - Spacer() - Image(systemName: "checkmark") - } - } - } - ) - } else { - return AnyView( - Menu { - Button { - selectedGroupId = group.id - } label: { - HStack { - Image(nsImage: colorDot(group.color.color)) - Text(group.name) - if selectedGroupId == group.id { - Spacer() - Image(systemName: "checkmark") - } - } - } - - Divider() - - ForEach(subgroups) { child in - groupMenuItem(child) - } - } label: { - HStack { - Image(nsImage: colorDot(group.color.color)) - Text(group.name) - if selectedGroupId == group.id { - Spacer() - Image(systemName: "checkmark") - } - } - } - ) - } - } - - private func colorDot(_ color: Color) -> NSImage { - let size = NSSize(width: 10, height: 10) - let image = NSImage(size: size, flipped: false) { rect in - NSColor(color).setFill() - NSBezierPath(ovalIn: rect).fill() - return true - } - image.isTemplate = false - return image - } - - private func deleteGroup(_ group: ConnectionGroup) { - if selectedGroupId == group.id { - selectedGroupId = nil - } - groupStorage.deleteGroup(group) - allGroups = groupStorage.loadGroups() - } -} diff --git a/TablePro/Views/Connection/ConnectionGroupPicker.swift b/TablePro/Views/Connection/ConnectionGroupPicker.swift index 473ee262..d65860a0 100644 --- a/TablePro/Views/Connection/ConnectionGroupPicker.swift +++ b/TablePro/Views/Connection/ConnectionGroupPicker.swift @@ -2,12 +2,10 @@ // ConnectionGroupPicker.swift // TablePro // -// Group selector dropdown for connection form -// import SwiftUI -/// Group selection for a connection — single Menu dropdown +/// Group selection dropdown for the connection form struct ConnectionGroupPicker: View { @Binding var selectedGroupId: UUID? @State private var allGroups: [ConnectionGroup] = [] @@ -17,12 +15,17 @@ struct ConnectionGroupPicker: View { private var selectedGroup: ConnectionGroup? { guard let id = selectedGroupId else { return nil } - return allGroups.first { $0.id == id } + return groupStorage.group(for: id) + } + + private func children(of parentId: UUID?) -> [ConnectionGroup] { + allGroups + .filter { $0.parentGroupId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } } var body: some View { Menu { - // None option Button { selectedGroupId = nil } label: { @@ -35,42 +38,42 @@ struct ConnectionGroupPicker: View { } } - Divider() + let rootGroups = children(of: nil) + if !rootGroups.isEmpty { + Divider() + } - // Available groups - ForEach(allGroups) { group in - Button { - selectedGroupId = group.id - } label: { - HStack { - if !group.color.isDefault { - Image(nsImage: colorDot(group.color.color)) - } - Text(group.name) - if selectedGroupId == group.id { - Spacer() - Image(systemName: "checkmark") - } - } - } + ForEach(rootGroups) { group in + groupMenuItem(group) } Divider() - // Create new group Button { showingCreateSheet = true } label: { Label("Create New Group...", systemImage: "plus.circle") } + + if !allGroups.isEmpty { + Divider() + + Menu("Manage Groups") { + ForEach(allGroups) { group in + Button(role: .destructive) { + deleteGroup(group) + } label: { + Label("Delete \"\(group.name)\"", systemImage: "trash") + } + } + } + } } label: { HStack(spacing: 6) { if let group = selectedGroup { - if !group.color.isDefault { - Circle() - .fill(group.color.color) - .frame(width: 8, height: 8) - } + Image(systemName: "folder.fill") + .foregroundStyle(group.color.color) + .font(.system(size: 10)) Text(group.name) .foregroundStyle(.primary) } else { @@ -83,16 +86,68 @@ struct ConnectionGroupPicker: View { .fixedSize() .task { allGroups = groupStorage.loadGroups() } .sheet(isPresented: $showingCreateSheet) { - CreateGroupSheet { groupName, groupColor in - let group = ConnectionGroup(name: groupName, color: groupColor) - groupStorage.addGroup(group) - selectedGroupId = group.id + ConnectionGroupFormSheet { newGroup in + groupStorage.addGroup(newGroup) + selectedGroupId = newGroup.id allGroups = groupStorage.loadGroups() } } } - /// Create a colored circle NSImage for use in menu items + // MARK: - Helpers + + private func groupMenuItem(_ group: ConnectionGroup) -> AnyView { + let subgroups = children(of: group.id) + if subgroups.isEmpty { + return AnyView( + Button { + selectedGroupId = group.id + } label: { + HStack { + Image(nsImage: colorDot(group.color.color)) + Text(group.name) + if selectedGroupId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + ) + } else { + return AnyView( + Menu { + Button { + selectedGroupId = group.id + } label: { + HStack { + Image(nsImage: colorDot(group.color.color)) + Text(group.name) + if selectedGroupId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(subgroups) { child in + groupMenuItem(child) + } + } label: { + HStack { + Image(nsImage: colorDot(group.color.color)) + Text(group.name) + if selectedGroupId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + ) + } + } + private func colorDot(_ color: Color) -> NSImage { let size = NSSize(width: 10, height: 10) let image = NSImage(size: size, flipped: false) { rect in @@ -103,94 +158,12 @@ struct ConnectionGroupPicker: View { image.isTemplate = false return image } -} - -// MARK: - Create Group Sheet -struct CreateGroupSheet: View { - @Environment(\.dismiss) private var dismiss - @State private var groupName: String = "" - @State private var groupColor: ConnectionColor = .none - let onSave: (String, ConnectionColor) -> Void - - var body: some View { - VStack(spacing: 16) { - Text("Create New Group") - .font(.headline) - - TextField("Group name", text: $groupName) - .textFieldStyle(.roundedBorder) - .frame(width: 200) - - VStack(alignment: .leading, spacing: 6) { - Text("Color") - .font(.caption) - .foregroundStyle(.secondary) - GroupColorPicker(selectedColor: $groupColor) - } - - HStack { - Button("Cancel") { - dismiss() - } - - Button("Create") { - onSave(groupName, groupColor) - dismiss() - } - .keyboardShortcut(.return) - .buttonStyle(.borderedProminent) - .disabled(groupName.trimmingCharacters(in: .whitespaces).isEmpty) - } - } - .padding(20) - .frame(width: 300) - .onExitCommand { - dismiss() - } - } -} - -// MARK: - Group Color Picker - -private struct GroupColorPicker: View { - @Binding var selectedColor: ConnectionColor - - var body: some View { - HStack(spacing: 6) { - ForEach(ConnectionColor.allCases) { color in - Circle() - .fill(color == .none ? Color(nsColor: .quaternaryLabelColor) : color.color) - .frame(width: DesignConstants.IconSize.medium, height: DesignConstants.IconSize.medium) - .overlay( - Circle() - .stroke(Color.primary, lineWidth: selectedColor == color ? 2 : 0) - .frame( - width: DesignConstants.IconSize.large, - height: DesignConstants.IconSize.large - ) - ) - .onTapGesture { - selectedColor = color - } - } - } - } -} - -#Preview { - struct PreviewWrapper: View { - @State private var groupId: UUID? - - var body: some View { - VStack(spacing: 20) { - ConnectionGroupPicker(selectedGroupId: $groupId) - Text("Selected: \(groupId?.uuidString ?? "none")") - } - .padding() - .frame(width: 400) + private func deleteGroup(_ group: ConnectionGroup) { + if selectedGroupId == group.id { + selectedGroupId = nil } + groupStorage.deleteGroup(group) + allGroups = groupStorage.loadGroups() } - - return PreviewWrapper() } diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift index ebb0b833..9c9cfdb9 100644 --- a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -55,6 +55,7 @@ struct ConnectionOutlineView: NSViewRepresentable { var onDeleteGroup: ((ConnectionGroup) -> Void)? var onEditConnection: ((DatabaseConnection) -> Void)? var onDuplicateConnection: ((DatabaseConnection) -> Void)? + var onCopyConnectionURL: ((DatabaseConnection) -> Void)? var onDeleteConnection: ((DatabaseConnection) -> Void)? var onMoveConnectionToGroup: ((DatabaseConnection, UUID?) -> Void)? @@ -506,6 +507,7 @@ extension ConnectionOutlineView { conn.name.lowercased().contains(query) || conn.host.lowercased().contains(query) || conn.database.lowercased().contains(query) + || parent.groups.first(where: { $0.id == conn.groupId })?.name.lowercased().contains(query) == true } .sorted { $0.sortOrder < $1.sortOrder } @@ -1050,6 +1052,7 @@ extension ConnectionOutlineView { keyEquivalent: "" ) newConnItem.target = self + newConnItem.image = NSImage(systemSymbolName: "plus", accessibilityDescription: nil) menu.addItem(newConnItem) let newSubgroupItem = NSMenuItem( @@ -1059,6 +1062,7 @@ extension ConnectionOutlineView { ) newSubgroupItem.target = self newSubgroupItem.representedObject = group.id + newSubgroupItem.image = NSImage(systemSymbolName: "folder.badge.plus", accessibilityDescription: nil) menu.addItem(newSubgroupItem) menu.addItem(.separator()) @@ -1070,6 +1074,7 @@ extension ConnectionOutlineView { ) editItem.target = self editItem.representedObject = group + editItem.image = NSImage(systemSymbolName: "pencil", accessibilityDescription: nil) menu.addItem(editItem) menu.addItem(.separator()) @@ -1081,6 +1086,7 @@ extension ConnectionOutlineView { ) deleteItem.target = self deleteItem.representedObject = group + deleteItem.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil) deleteItem.setDestructiveStyle() menu.addItem(deleteItem) @@ -1098,6 +1104,7 @@ extension ConnectionOutlineView { ) connectItem.target = self connectItem.representedObject = connection + connectItem.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: nil) menu.addItem(connectItem) menu.addItem(.separator()) @@ -1109,6 +1116,7 @@ extension ConnectionOutlineView { ) editItem.target = self editItem.representedObject = connection + editItem.image = NSImage(systemSymbolName: "pencil", accessibilityDescription: nil) menu.addItem(editItem) let duplicateItem = NSMenuItem( @@ -1118,8 +1126,19 @@ extension ConnectionOutlineView { ) duplicateItem.target = self duplicateItem.representedObject = connection + duplicateItem.image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: nil) menu.addItem(duplicateItem) + let copyURLItem = NSMenuItem( + title: String(localized: "Copy as URL"), + action: #selector(contextMenuCopyConnectionURL(_:)), + keyEquivalent: "" + ) + copyURLItem.target = self + copyURLItem.representedObject = connection + copyURLItem.image = NSImage(systemSymbolName: "link", accessibilityDescription: nil) + menu.addItem(copyURLItem) + menu.addItem(.separator()) // Move to Group submenu @@ -1149,6 +1168,7 @@ extension ConnectionOutlineView { let moveItem = NSMenuItem(title: String(localized: "Move to Group"), action: nil, keyEquivalent: "") moveItem.submenu = moveMenu + moveItem.image = NSImage(systemSymbolName: "folder", accessibilityDescription: nil) menu.addItem(moveItem) menu.addItem(.separator()) @@ -1160,6 +1180,7 @@ extension ConnectionOutlineView { ) deleteItem.target = self deleteItem.representedObject = connection + deleteItem.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil) deleteItem.setDestructiveStyle() menu.addItem(deleteItem) @@ -1175,6 +1196,7 @@ extension ConnectionOutlineView { keyEquivalent: "" ) newConnItem.target = self + newConnItem.image = NSImage(systemSymbolName: "plus", accessibilityDescription: nil) menu.addItem(newConnItem) let newGroupItem = NSMenuItem( @@ -1183,6 +1205,7 @@ extension ConnectionOutlineView { keyEquivalent: "" ) newGroupItem.target = self + newGroupItem.image = NSImage(systemSymbolName: "folder.badge.plus", accessibilityDescription: nil) menu.addItem(newGroupItem) return menu @@ -1228,6 +1251,11 @@ extension ConnectionOutlineView { parent.onDuplicateConnection?(connection) } + @objc private func contextMenuCopyConnectionURL(_ sender: NSMenuItem) { + guard let connection = sender.representedObject as? DatabaseConnection else { return } + parent.onCopyConnectionURL?(connection) + } + @objc private func contextMenuDeleteConnection(_ sender: NSMenuItem) { guard let connection = sender.representedObject as? DatabaseConnection else { return } parent.onDeleteConnection?(connection) diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index b86badff..438df48f 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -42,6 +42,8 @@ struct WelcomeWindowView: View { connection.name.localizedCaseInsensitiveContains(searchText) || connection.host.localizedCaseInsensitiveContains(searchText) || connection.database.localizedCaseInsensitiveContains(searchText) + || groups.first(where: { $0.id == connection.groupId })?.name + .localizedCaseInsensitiveContains(searchText) == true } } @@ -201,6 +203,24 @@ struct WelcomeWindowView: View { .buttonStyle(.plain) .help("New Connection (⌘N)") + Button(action: { + groupFormContext = GroupFormContext(group: nil, parentGroupId: nil) + }) { + Image(systemName: "folder.badge.plus") + .font(.system(size: DesignConstants.FontSize.medium, weight: .medium)) + .foregroundStyle(.secondary) + .frame( + width: DesignConstants.IconSize.extraLarge, + height: DesignConstants.IconSize.extraLarge + ) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .quaternaryLabelColor)) + ) + } + .buttonStyle(.plain) + .help(String(localized: "New Group")) + HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: DesignConstants.FontSize.medium)) @@ -293,6 +313,16 @@ struct WelcomeWindowView: View { onDuplicateConnection: { connection in duplicateConnection(connection) }, + onCopyConnectionURL: { connection in + Task.detached { + let pw = ConnectionStorage.shared.loadPassword(for: connection.id) + let sshPw = ConnectionStorage.shared.loadSSHPassword(for: connection.id) + let url = ConnectionURLFormatter.format(connection, password: pw, sshPassword: sshPw) + await MainActor.run { + ClipboardService.shared.writeText(url) + } + } + }, onDeleteConnection: { connection in connectionToDelete = connection showDeleteConfirmation = true From b76144fef926725aaff0eaed1329cb9843a44de5 Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:32:53 +0700 Subject: [PATCH 08/16] fix: address Copilot PR review comments and add backspace delete support --- TablePro/Core/Storage/ConnectionStorage.swift | 9 ++- TablePro/Core/Storage/GroupStorage.swift | 6 +- .../Connection/ConnectionGroupFormSheet.swift | 8 ++- .../WelcomeWindow/ConnectionOutlineView.swift | 29 +++++---- TablePro/Views/WelcomeWindowView.swift | 61 +++++++++++++------ 5 files changed, 81 insertions(+), 32 deletions(-) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index a653ce72..893ddb8e 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -115,9 +115,16 @@ final class ConnectionStorage { username: connection.username, type: connection.type, sshConfig: connection.sshConfig, + sslConfig: connection.sslConfig, color: connection.color, tagId: connection.tagId, - groupId: connection.groupId + groupId: connection.groupId, + isReadOnly: connection.isReadOnly, + aiPolicy: connection.aiPolicy, + mongoReadPreference: connection.mongoReadPreference, + mongoWriteConcern: connection.mongoWriteConcern, + sortOrder: connection.sortOrder, + redisDatabase: connection.redisDatabase ) // Save the duplicate connection diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index ef9a35b6..b2d6bd02 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -45,9 +45,13 @@ final class GroupStorage { } } - /// Add a new group + /// Add a new group (rejects case-insensitive duplicate names) func addGroup(_ group: ConnectionGroup) { var groups = loadGroups() + if groups.contains(where: { $0.name.caseInsensitiveCompare(group.name) == .orderedSame }) { + Self.logger.debug("Ignoring attempt to add duplicate group name: \(group.name, privacy: .public)") + return + } groups.append(group) saveGroups(groups) } diff --git a/TablePro/Views/Connection/ConnectionGroupFormSheet.swift b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift index 970ecbd9..8ed96a89 100644 --- a/TablePro/Views/Connection/ConnectionGroupFormSheet.swift +++ b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift @@ -98,9 +98,15 @@ struct ConnectionGroupFormSheet: View { guard !trimmedName.isEmpty else { return } if var existing = group { + let originalParentId = existing.parentGroupId existing.name = trimmedName existing.color = color - existing.parentGroupId = selectedParentId + if originalParentId != selectedParentId { + existing.parentGroupId = selectedParentId + existing.sortOrder = groupStorage.nextSortOrder(parentId: selectedParentId) + } else { + existing.parentGroupId = selectedParentId + } onSave?(existing) } else { let sortOrder = groupStorage.nextSortOrder(parentId: selectedParentId) diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift index 9c9cfdb9..94030219 100644 --- a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -43,7 +43,6 @@ struct ConnectionOutlineView: NSViewRepresentable { var onSelectionChanged: ((UUID?) -> Void)? var onDoubleClickConnection: ((DatabaseConnection) -> Void)? var onToggleGroup: ((UUID) -> Void)? - var onMoveConnection: ((DatabaseConnection, UUID?) -> Void)? var onReorderConnections: (([DatabaseConnection]) -> Void)? var onReorderGroups: (([ConnectionGroup]) -> Void)? var onMoveGroup: ((ConnectionGroup, UUID?) -> Void)? @@ -397,7 +396,7 @@ final class ConnectionNSOutlineView: NSOutlineView { weak var coordinator: ConnectionOutlineView.Coordinator? override func drawBackground(inClipRect clipRect: NSRect) { - // Sip the translucent gray; SwiftUI parent background shows through + // Skip the translucent gray; SwiftUI parent background shows through } override func menu(for event: NSEvent) -> NSMenu? { @@ -433,6 +432,18 @@ final class ConnectionNSOutlineView: NSOutlineView { return } } + // Delete/Backspace key deletes the selected item + if event.keyCode == 51 { + let row = selectedRow + if row >= 0, let outlineConn = item(atRow: row) as? OutlineConnection { + coordinator?.parent.onDeleteConnection?(outlineConn.connection) + return + } + if row >= 0, let outlineGroup = item(atRow: row) as? OutlineGroup { + coordinator?.parent.onDeleteGroup?(outlineGroup.group) + return + } + } super.keyDown(with: event) } } @@ -501,17 +512,11 @@ extension ConnectionOutlineView { lastSnapshotHash = computeSnapshotHash(groups: groups, connections: connections, searchText: searchText) if isSearchMode { - // Flat filtered list of connections only - let query = searchText.lowercased() - let filtered = connections.filter { conn in - conn.name.lowercased().contains(query) - || conn.host.lowercased().contains(query) - || conn.database.lowercased().contains(query) - || parent.groups.first(where: { $0.id == conn.groupId })?.name.lowercased().contains(query) == true - } - .sorted { $0.sortOrder < $1.sortOrder } + // In search mode, `connections` is already filtered by the caller. + // Build a flat, sorted list of those connections. + let sortedConnections = connections.sorted { $0.sortOrder < $1.sortOrder } - for conn in filtered { + for conn in sortedConnections { let item = OutlineConnection(conn) allConnectionItems[conn.id] = item rootItems.append(item) diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index 438df48f..ba735ebf 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -243,12 +243,14 @@ struct WelcomeWindowView: View { Divider() // Connection list - if connections.isEmpty, groups.isEmpty { - emptyState - } else if !searchText.isEmpty, filteredConnections.isEmpty { - emptyState - } else { + ZStack { connectionList + + if connections.isEmpty, groups.isEmpty { + emptyState + } else if !searchText.isEmpty, filteredConnections.isEmpty { + emptyState + } } } .frame(minWidth: 350) @@ -272,19 +274,33 @@ struct WelcomeWindowView: View { onToggleGroup: { groupId in toggleGroup(groupId) }, - onMoveConnection: { connection, newGroupId in - moveConnectionToGroup(connection, groupId: newGroupId) - }, onReorderConnections: { reorderedConns in - for conn in reorderedConns { - storage.updateConnection(conn) + let updatedIds = Dictionary(uniqueKeysWithValues: reorderedConns.map { ($0.id, $0) }) + var allConns = connections + for index in allConns.indices { + if let updated = updatedIds[allConns[index].id] { + allConns[index] = updated + } } - loadConnections() + // Append any new entries (e.g. moved from another group) + for conn in reorderedConns where !allConns.contains(where: { $0.id == conn.id }) { + allConns.append(conn) + } + storage.saveConnections(allConns) + connections = allConns }, onReorderGroups: { reorderedGroups in - for group in reorderedGroups { - groupStorage.updateGroup(group) + let updatedIds = Dictionary(uniqueKeysWithValues: reorderedGroups.map { ($0.id, $0) }) + var allGroups = groups + for index in allGroups.indices { + if let updated = updatedIds[allGroups[index].id] { + allGroups[index] = updated + } + } + for grp in reorderedGroups where !allGroups.contains(where: { $0.id == grp.id }) { + allGroups.append(grp) } + groupStorage.saveGroups(allGroups) loadConnections() }, onMoveGroup: { group, newParentId in @@ -373,9 +389,7 @@ struct WelcomeWindowView: View { connections = saved } groups = groupStorage.loadGroups() - let savedExpanded = groupStorage.loadExpandedGroupIds() - // Auto-expand new groups - expandedGroups = savedExpanded.union(Set(groups.map(\.id))) + expandedGroups = groupStorage.loadExpandedGroupIds() } private func connectToDatabase(_ connection: DatabaseConnection) { @@ -447,8 +461,11 @@ struct WelcomeWindowView: View { } private func deleteGroup(_ group: ConnectionGroup) { + let allGroups = groupStorage.loadGroups() + let descendantIds = collectDescendantIds(of: group.id, in: allGroups) + let allDeletedIds = descendantIds.union([group.id]) groupStorage.deleteGroup(group) - expandedGroups.remove(group.id) + expandedGroups.subtract(allDeletedIds) groupStorage.saveExpandedGroupIds(expandedGroups) loadConnections() } @@ -460,6 +477,16 @@ struct WelcomeWindowView: View { loadConnections() } + private func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set { + var result = Set() + let children = groups.filter { $0.parentGroupId == groupId } + for child in children { + result.insert(child.id) + result.formUnion(collectDescendantIds(of: child.id, in: groups)) + } + return result + } + private func toggleGroup(_ groupId: UUID) { if expandedGroups.contains(groupId) { expandedGroups.remove(groupId) From 2d2bfb7fc6c05d7ed27d41ec3410e647db48826a Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:34:29 +0700 Subject: [PATCH 09/16] feat: multi-selection, 2-step delete confirmation, and drag-drop improvements --- CHANGELOG.md | 2 +- TablePro/Core/Storage/ConnectionStorage.swift | 14 +- TablePro/Core/Storage/GroupStorage.swift | 37 +- TablePro/Resources/Localizable.xcstrings | 78 +++- .../Views/Connection/ConnectionFormView.swift | 5 +- .../WelcomeWindow/ConnectionOutlineView.swift | 392 ++++++++++++++---- TablePro/Views/WelcomeWindowView.swift | 192 +++++++-- 7 files changed, 581 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b68f7428..f6b9fa05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Connection groups: organize connections into named, color-coded folders with support for nested subgroups, drag-and-drop reordering, expand/collapse state persistence, and context menus for group and connection management +- Connection groups: organize connections into named, color-coded folders with support for nested subgroups, drag-and-drop reordering, expand/collapse state persistence, multi-selection (bulk delete, bulk move to group), and context menus for group and connection management ## [0.13.0] - 2026-03-04 diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 893ddb8e..9325a94e 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -127,9 +127,19 @@ final class ConnectionStorage { redisDatabase: connection.redisDatabase ) - // Save the duplicate connection + // Insert duplicate right after the original by shifting siblings var connections = loadConnections() - connections.append(duplicate) + let newSortOrder = connection.sortOrder + 1 + for index in connections.indices { + if connections[index].groupId == connection.groupId, + connections[index].sortOrder >= newSortOrder + { + connections[index].sortOrder += 1 + } + } + var placed = duplicate + placed.sortOrder = newSortOrder + connections.append(placed) saveConnections(connections) // Copy all passwords from source to duplicate diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index b2d6bd02..2be35857 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -65,8 +65,7 @@ final class GroupStorage { } } - /// Delete a group and all its descendants. - /// Member connections become ungrouped. + /// Delete a group and all its descendants, including their connections. func deleteGroup(_ group: ConnectionGroup) { var groups = loadGroups() let deletedIds = collectDescendantIds(of: group.id, in: groups) @@ -76,19 +75,33 @@ final class GroupStorage { groups.removeAll { allDeletedIds.contains($0.id) } saveGroups(groups) - // Ungroup connections that belonged to deleted groups + // Delete connections that belonged to deleted groups let storage = ConnectionStorage.shared - var connections = storage.loadConnections() - var changed = false - for index in connections.indices { - if let gid = connections[index].groupId, allDeletedIds.contains(gid) { - connections[index].groupId = nil - changed = true + let connections = storage.loadConnections() + var remaining: [DatabaseConnection] = [] + for conn in connections { + if let gid = conn.groupId, allDeletedIds.contains(gid) { + // Clean up keychain entries + storage.deletePassword(for: conn.id) + storage.deleteSSHPassword(for: conn.id) + storage.deleteKeyPassphrase(for: conn.id) + } else { + remaining.append(conn) } } - if changed { - storage.saveConnections(connections) - } + storage.saveConnections(remaining) + } + + /// Count all connections inside a group and its descendants. + func connectionCount(for group: ConnectionGroup) -> Int { + let allGroups = loadGroups() + let descendantIds = collectDescendantIds(of: group.id, in: allGroups) + let allGroupIds = descendantIds.union([group.id]) + let connections = ConnectionStorage.shared.loadConnections() + return connections.filter { conn in + guard let gid = conn.groupId else { return false } + return allGroupIds.contains(gid) + }.count } /// Get group by ID diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 728c3399..c0cf126c 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -320,6 +320,10 @@ } } }, + "%lld connection(s) will be permanently deleted. This cannot be undone." : { + "comment" : "A message displayed in the second step of a confirmation dialog for permanently deleting multiple connections. The argument is the count of connections that will be deleted.", + "isCommentAutoGenerated" : true + }, "%lld in · %lld out" : { "localizations" : { "en" : { @@ -1226,10 +1230,6 @@ } } }, - "Are you sure you want to delete \"%@\"? Connections will be ungrouped." : { - "comment" : "A confirmation dialog message asking the user to confirm the deletion of a connection group.", - "isCommentAutoGenerated" : true - }, "Are you sure you want to disconnect from this database?" : { "localizations" : { "vi" : { @@ -2920,6 +2920,22 @@ } } }, + "Delete \"%@\" and its %lld connection(s)?" : { + "comment" : "A confirmation message asking whether to delete a group and all its connections. The placeholder inside the parentheses should be replaced with the name of the group.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete \"%1$@\" and its %2$lld connection(s)?" + } + } + } + }, + "Delete \"%@\"?" : { + "comment" : "A confirmation message for deleting a single medication group.", + "isCommentAutoGenerated" : true + }, "Delete (⌫)" : { "extractionState" : "stale", "localizations" : { @@ -2931,6 +2947,52 @@ } } }, + "Delete %lld Connection(s)" : { + + }, + "Delete %lld Connections" : { + "comment" : "Text for a menu item in the \"Move to Group\" context menu, describing the number of connections being moved.", + "isCommentAutoGenerated" : true + }, + "Delete %lld connections?" : { + "comment" : "A message in the \"Delete\" dialog that asks the user to confirm deleting multiple connections.", + "isCommentAutoGenerated" : true + }, + "Delete %lld group(s) and %lld connection(s) total?" : { + "comment" : "A confirmation message for deleting multiple groups and connections. The first argument is the count of groups. The second argument is the count of connections.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete %1$lld group(s) and %2$lld connection(s) total?" + } + } + } + }, + "Delete %lld Groups" : { + + }, + "Delete %lld groups and %lld connection(s) inside?" : { + "comment" : "A question asking the user to confirm deleting multiple groups, including how many connections will be affected.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete %1$lld groups and %2$lld connection(s) inside?" + } + } + } + }, + "Delete %lld groups?" : { + "comment" : "A confirmation message for deleting multiple groups.", + "isCommentAutoGenerated" : true + }, + "Delete %lld Items" : { + "comment" : "A title for a menu item that deletes multiple items (either connections or groups). The number in the title corresponds to the total number of items to be deleted.", + "isCommentAutoGenerated" : true + }, "Delete Check Constraint" : { "extractionState" : "stale", "localizations" : { @@ -2963,6 +3025,10 @@ } } }, + "Delete Connections" : { + "comment" : "A button label that triggers the confirmation dialog for deleting multiple connections.", + "isCommentAutoGenerated" : true + }, "Delete Foreign Key" : { "extractionState" : "stale", "localizations" : { @@ -2978,6 +3044,10 @@ "comment" : "A confirmation dialog title for deleting a database connection group.", "isCommentAutoGenerated" : true }, + "Delete Groups" : { + "comment" : "A button that triggers a confirmation dialog for deleting multiple groups.", + "isCommentAutoGenerated" : true + }, "Delete Index" : { "extractionState" : "stale", "localizations" : { diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index f7131990..fa0cd1fd 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -684,7 +684,10 @@ struct ConnectionFormView: View { connectToDatabase(connectionToSave) } else { if let index = savedConnections.firstIndex(where: { $0.id == connectionToSave.id }) { - savedConnections[index] = connectionToSave + // Preserve sortOrder from existing connection + var updated = connectionToSave + updated.sortOrder = savedConnections[index].sortOrder + savedConnections[index] = updated storage.saveConnections(savedConnections) } NSApplication.shared.closeWindows(withId: "connection-form") diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift index 94030219..6a1c51a6 100644 --- a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -36,11 +36,9 @@ struct ConnectionOutlineView: NSViewRepresentable { let groups: [ConnectionGroup] let connections: [DatabaseConnection] var expandedGroupIds: Set - var selectedItemId: UUID? var searchText: String // Callbacks - var onSelectionChanged: ((UUID?) -> Void)? var onDoubleClickConnection: ((DatabaseConnection) -> Void)? var onToggleGroup: ((UUID) -> Void)? var onReorderConnections: (([DatabaseConnection]) -> Void)? @@ -51,13 +49,15 @@ struct ConnectionOutlineView: NSViewRepresentable { var onNewConnection: (() -> Void)? var onNewGroup: ((UUID?) -> Void)? var onEditGroup: ((ConnectionGroup) -> Void)? - var onDeleteGroup: ((ConnectionGroup) -> Void)? var onEditConnection: ((DatabaseConnection) -> Void)? var onDuplicateConnection: ((DatabaseConnection) -> Void)? var onCopyConnectionURL: ((DatabaseConnection) -> Void)? - var onDeleteConnection: ((DatabaseConnection) -> Void)? var onMoveConnectionToGroup: ((DatabaseConnection, UUID?) -> Void)? + // Delete & bulk callbacks + var onRequestDelete: (([ConnectionGroup], [DatabaseConnection]) -> Void)? + var onMoveConnectionsToGroup: (([DatabaseConnection], UUID?) -> Void)? + // MARK: - NSViewRepresentable func makeCoordinator() -> Coordinator { @@ -77,7 +77,7 @@ struct ConnectionOutlineView: NSViewRepresentable { outlineView.rowHeight = DesignConstants.RowHeight.comfortable outlineView.style = .sourceList outlineView.selectionHighlightStyle = .regular - outlineView.allowsMultipleSelection = false + outlineView.allowsMultipleSelection = true outlineView.autosaveExpandedItems = false outlineView.floatsGroupRows = false outlineView.rowSizeStyle = .default @@ -104,8 +104,7 @@ struct ConnectionOutlineView: NSViewRepresentable { context.coordinator.outlineView = outlineView context.coordinator.rebuildData(groups: groups, connections: connections, searchText: searchText) outlineView.reloadData() - syncExpandedState(outlineView: outlineView, coordinator: context.coordinator) - syncSelection(outlineView: outlineView, coordinator: context.coordinator) + restoreExpandedState(outlineView: outlineView, coordinator: context.coordinator, expandedIds: expandedGroupIds) return scrollView } @@ -126,67 +125,69 @@ struct ConnectionOutlineView: NSViewRepresentable { ) if needsReload { + // Capture current state before reload destroys it + let currentExpanded = coordinator.captureExpandedGroupIds(from: outlineView) + let scrollView = outlineView.enclosingScrollView + let savedOrigin = scrollView?.contentView.bounds.origin + coordinator.rebuildData(groups: groups, connections: connections, searchText: searchText) outlineView.reloadData() - syncExpandedState(outlineView: outlineView, coordinator: coordinator) - } - syncSelection(outlineView: outlineView, coordinator: coordinator) + restoreExpandedState( + outlineView: outlineView, + coordinator: coordinator, + expandedIds: currentExpanded.union(expandedGroupIds) + ) + + // Restore scroll position, clamped to new content bounds + if let scrollView, let savedOrigin { + let maxY = max(0, outlineView.frame.height - scrollView.contentView.bounds.height) + let clampedY = min(savedOrigin.y, maxY) + scrollView.contentView.scroll(to: NSPoint(x: savedOrigin.x, y: clampedY)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + } } // MARK: - State Sync - private func syncExpandedState(outlineView: NSOutlineView, coordinator: Coordinator) { + /// Restore expanded state after a reload using the given set of IDs. + private func restoreExpandedState( + outlineView: NSOutlineView, + coordinator: Coordinator, + expandedIds: Set + ) { NSAnimationContext.beginGrouping() NSAnimationContext.current.duration = 0 for item in coordinator.rootItems { - syncExpandedStateRecursive(outlineView: outlineView, item: item, coordinator: coordinator) + restoreExpandedStateRecursive( + outlineView: outlineView, item: item, coordinator: coordinator, expandedIds: expandedIds + ) } NSAnimationContext.endGrouping() } - private func syncExpandedStateRecursive( + private func restoreExpandedStateRecursive( outlineView: NSOutlineView, item: NSObject, - coordinator: Coordinator + coordinator: Coordinator, + expandedIds: Set ) { guard let outlineGroup = item as? OutlineGroup else { return } - let shouldExpand = expandedGroupIds.contains(outlineGroup.group.id) - let isExpanded = outlineView.isItemExpanded(item) - - if shouldExpand && !isExpanded { + if expandedIds.contains(outlineGroup.group.id) { outlineView.expandItem(item) - } else if !shouldExpand && isExpanded { - outlineView.collapseItem(item) } - - // Recurse into children if let children = coordinator.childrenMap[outlineGroup.group.id] { for child in children { - syncExpandedStateRecursive(outlineView: outlineView, item: child, coordinator: coordinator) + restoreExpandedStateRecursive( + outlineView: outlineView, item: child, coordinator: coordinator, expandedIds: expandedIds + ) } } } - private func syncSelection(outlineView: NSOutlineView, coordinator: Coordinator) { - guard let targetId = selectedItemId else { - if outlineView.selectedRow != -1 { - outlineView.deselectAll(nil) - } - return - } - - if let item = coordinator.itemById(targetId) { - let row = outlineView.row(forItem: item) - if row >= 0 && outlineView.selectedRow != row { - coordinator.isSyncingSelection = true - outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - coordinator.isSyncingSelection = false - } - } - } } // MARK: - Pasteboard Type @@ -404,10 +405,17 @@ final class ConnectionNSOutlineView: NSOutlineView { let clickedRow = row(at: point) if clickedRow >= 0 { - // Select the row under right-click - selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) - let item = self.item(atRow: clickedRow) + // If clicked row is already in selection, keep multi-selection + if !selectedRowIndexes.contains(clickedRow) { + selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) + } + + // Multi-selection context menu + if selectedRowIndexes.count > 1 { + return coordinator?.multiSelectionContextMenu(outlineView: self) + } + let item = self.item(atRow: clickedRow) if let outlineGroup = item as? OutlineGroup { return coordinator?.contextMenu(for: outlineGroup) } else if let outlineConn = item as? OutlineConnection { @@ -432,17 +440,21 @@ final class ConnectionNSOutlineView: NSOutlineView { return } } - // Delete/Backspace key deletes the selected item - if event.keyCode == 51 { - let row = selectedRow - if row >= 0, let outlineConn = item(atRow: row) as? OutlineConnection { - coordinator?.parent.onDeleteConnection?(outlineConn.connection) - return + // Delete/Backspace key deletes selected items + if event.keyCode == 51, !selectedRowIndexes.isEmpty { + var selectedConnections: [DatabaseConnection] = [] + var selectedGroups: [ConnectionGroup] = [] + for row in selectedRowIndexes { + if let outlineConn = item(atRow: row) as? OutlineConnection { + selectedConnections.append(outlineConn.connection) + } else if let outlineGroup = item(atRow: row) as? OutlineGroup { + selectedGroups.append(outlineGroup.group) + } } - if row >= 0, let outlineGroup = item(atRow: row) as? OutlineGroup { - coordinator?.parent.onDeleteGroup?(outlineGroup.group) - return + if !selectedGroups.isEmpty || !selectedConnections.isEmpty { + coordinator?.parent.onRequestDelete?(selectedGroups, selectedConnections) } + return } super.keyDown(with: event) } @@ -456,9 +468,9 @@ extension ConnectionOutlineView { var parent: ConnectionOutlineView weak var outlineView: ConnectionNSOutlineView? - var isSyncingSelection = false var isDragging = false private var draggedItemId: UUID? + private var draggedItemIds: Set = [] // Data model var rootItems: [NSObject] = [] @@ -591,6 +603,17 @@ extension ConnectionOutlineView { return allConnectionItems[id] } + /// Capture which group IDs are currently expanded in the outline view. + func captureExpandedGroupIds(from outlineView: NSOutlineView) -> Set { + var expanded = Set() + for (groupId, item) in allGroupItems { + if outlineView.isItemExpanded(item) { + expanded.insert(groupId) + } + } + return expanded + } + // MARK: - NSOutlineViewDataSource func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { @@ -634,6 +657,34 @@ extension ConnectionOutlineView { isDragging = true + // On first item of a drag, capture all selected IDs + if draggedItemIds.isEmpty { + var hasGroup = false + var hasConnection = false + for row in outlineView.selectedRowIndexes { + if outlineView.item(atRow: row) is OutlineGroup { + hasGroup = true + } else if outlineView.item(atRow: row) is OutlineConnection { + hasConnection = true + } + } + // Don't allow multi-drag when groups are involved (moving a parent already moves children) + let isMultiSelection = outlineView.selectedRowIndexes.count > 1 + if isMultiSelection, hasGroup { + isDragging = false + return nil + } + + for row in outlineView.selectedRowIndexes { + if let conn = outlineView.item(atRow: row) as? OutlineConnection { + draggedItemIds.insert(conn.connection.id) + } + } + } + + // If multi-drag was blocked, reject subsequent items too + if !isDragging { return nil } + let pasteboardItem = NSPasteboardItem() if let outlineGroup = item as? OutlineGroup { draggedItemId = outlineGroup.group.id @@ -648,6 +699,7 @@ extension ConnectionOutlineView { func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { isDragging = false draggedItemId = nil + draggedItemIds = [] } // MARK: - Drop Validation @@ -790,19 +842,29 @@ extension ConnectionOutlineView { return false } + // Capture all dragged IDs before clearing state + let allDraggedIds = draggedItemIds.isEmpty ? [draggedId] : draggedItemIds + // Clear drag state before callbacks so the subsequent // SwiftUI state update → updateNSView is not blocked isDragging = false draggedItemId = nil + draggedItemIds = [] - if let draggedConnItem = allConnectionItems[draggedId] { - return acceptConnectionDrop( - connection: draggedConnItem.connection, + // Collect all dragged connections and groups + let draggedConnections = allDraggedIds.compactMap { allConnectionItems[$0]?.connection } + let draggedGroups = allDraggedIds.compactMap { allGroupItems[$0]?.group } + + // Multi-connection drop + if !draggedConnections.isEmpty, draggedGroups.isEmpty { + return acceptMultiConnectionDrop( + connections: draggedConnections, targetItem: item, childIndex: index ) } + // Single group drop (multi-group drag not supported) if let draggedGroupItem = allGroupItems[draggedId] { return acceptGroupDrop( group: draggedGroupItem.group, @@ -889,6 +951,45 @@ extension ConnectionOutlineView { return false } + private func acceptMultiConnectionDrop( + connections: [DatabaseConnection], + targetItem: Any?, + childIndex: Int + ) -> Bool { + // Single connection: use existing logic + if connections.count == 1 { + return acceptConnectionDrop( + connection: connections[0], + targetItem: targetItem, + childIndex: childIndex + ) + } + + let draggedIds = Set(connections.map(\.id)) + let targetGroupId: UUID? = (targetItem as? OutlineGroup)?.group.id + + // Get existing siblings excluding dragged items + var siblings = parent.connections + .filter { $0.groupId == targetGroupId && !draggedIds.contains($0.id) } + .sorted { $0.sortOrder < $1.sortOrder } + + // Append all dragged connections at the end + for var conn in connections { + conn.groupId = targetGroupId + conn.sortOrder = (siblings.last?.sortOrder ?? -1) + 1 + siblings.append(conn) + } + + // Renumber + for (order, var conn) in siblings.enumerated() { + conn.sortOrder = order + siblings[order] = conn + } + + parent.onReorderConnections?(siblings) + return true + } + private func acceptGroupDrop( group: ConnectionGroup, targetItem: Any?, @@ -987,26 +1088,6 @@ extension ConnectionOutlineView { return nil } - func outlineViewSelectionDidChange(_ notification: Notification) { - guard !isSyncingSelection else { return } - guard let outlineView = notification.object as? NSOutlineView else { return } - - let row = outlineView.selectedRow - guard row >= 0 else { - parent.onSelectionChanged?(nil) - return - } - - let item = outlineView.item(atRow: row) - if let outlineGroup = item as? OutlineGroup { - parent.onSelectionChanged?(outlineGroup.group.id) - } else if let outlineConn = item as? OutlineConnection { - parent.onSelectionChanged?(outlineConn.connection.id) - } else { - parent.onSelectionChanged?(nil) - } - } - func outlineViewItemDidExpand(_ notification: Notification) { guard let outlineGroup = notification.userInfo?["NSObject"] as? OutlineGroup else { return } let groupId = outlineGroup.group.id @@ -1216,6 +1297,80 @@ extension ConnectionOutlineView { return menu } + // MARK: - Multi-Selection Context Menu + + func multiSelectionContextMenu(outlineView: NSOutlineView) -> NSMenu { + var selectedConnections: [DatabaseConnection] = [] + var selectedGroups: [ConnectionGroup] = [] + for row in outlineView.selectedRowIndexes { + if let outlineConn = outlineView.item(atRow: row) as? OutlineConnection { + selectedConnections.append(outlineConn.connection) + } else if let outlineGroup = outlineView.item(atRow: row) as? OutlineGroup { + selectedGroups.append(outlineGroup.group) + } + } + + let menu = NSMenu() + + // Move to Group (only for connections) + if !selectedConnections.isEmpty, selectedGroups.isEmpty { + let moveMenu = NSMenu() + + let noneItem = NSMenuItem( + title: String(localized: "None"), + action: #selector(contextMenuBulkMoveToGroup(_:)), + keyEquivalent: "" + ) + noneItem.target = self + noneItem.representedObject = BulkMoveInfo(connections: selectedConnections, targetGroupId: nil) + moveMenu.addItem(noneItem) + + moveMenu.addItem(.separator()) + + let rootGroups = parent.groups + .filter { $0.parentGroupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + for group in rootGroups { + moveMenu.addItem(bulkMoveToGroupMenuItem(for: selectedConnections, group: group)) + } + + let moveItem = NSMenuItem( + title: String(localized: "Move to Group"), + action: nil, + keyEquivalent: "" + ) + moveItem.submenu = moveMenu + moveItem.image = NSImage(systemSymbolName: "folder", accessibilityDescription: nil) + menu.addItem(moveItem) + + menu.addItem(.separator()) + } + + // Delete + let totalCount = selectedConnections.count + selectedGroups.count + let deleteTitle: String + if !selectedConnections.isEmpty, selectedGroups.isEmpty { + deleteTitle = String(localized: "Delete \(selectedConnections.count) Connections") + } else if selectedConnections.isEmpty, !selectedGroups.isEmpty { + deleteTitle = String(localized: "Delete \(selectedGroups.count) Groups") + } else { + deleteTitle = String(localized: "Delete \(totalCount) Items") + } + + let deleteItem = NSMenuItem( + title: deleteTitle, + action: #selector(contextMenuBulkDelete(_:)), + keyEquivalent: "" + ) + deleteItem.target = self + deleteItem.representedObject = BulkDeleteInfo(connections: selectedConnections, groups: selectedGroups) + deleteItem.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil) + deleteItem.setDestructiveStyle() + menu.addItem(deleteItem) + + return menu + } + // MARK: - Context Menu Actions @objc private func contextMenuNewConnection() { @@ -1238,7 +1393,7 @@ extension ConnectionOutlineView { @objc private func contextMenuDeleteGroup(_ sender: NSMenuItem) { guard let group = sender.representedObject as? ConnectionGroup else { return } - parent.onDeleteGroup?(group) + parent.onRequestDelete?([group], []) } @objc private func contextMenuConnect(_ sender: NSMenuItem) { @@ -1263,7 +1418,7 @@ extension ConnectionOutlineView { @objc private func contextMenuDeleteConnection(_ sender: NSMenuItem) { guard let connection = sender.representedObject as? DatabaseConnection else { return } - parent.onDeleteConnection?(connection) + parent.onRequestDelete?([], [connection]) } /// Build an NSMenuItem for a group — if it has children, wrap in a submenu @@ -1327,6 +1482,65 @@ extension ConnectionOutlineView { guard let moveInfo = sender.representedObject as? ConnectionMoveInfo else { return } parent.onMoveConnectionToGroup?(moveInfo.connection, moveInfo.targetGroupId) } + + @objc private func contextMenuBulkDelete(_ sender: NSMenuItem) { + guard let info = sender.representedObject as? BulkDeleteInfo else { return } + parent.onRequestDelete?(info.groups, info.connections) + } + + @objc private func contextMenuBulkMoveToGroup(_ sender: NSMenuItem) { + guard let info = sender.representedObject as? BulkMoveInfo else { return } + parent.onMoveConnectionsToGroup?(info.connections, info.targetGroupId) + } + + private func bulkMoveToGroupMenuItem( + for connections: [DatabaseConnection], + group: ConnectionGroup + ) -> NSMenuItem { + let childGroups = parent.groups + .filter { $0.parentGroupId == group.id } + .sorted { $0.sortOrder < $1.sortOrder } + + let folderImage = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(paletteColors: [ + group.color.isDefault ? .secondaryLabelColor : NSColor(group.color.color), + ])) + + if childGroups.isEmpty { + let item = NSMenuItem( + title: group.name, + action: #selector(contextMenuBulkMoveToGroup(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = BulkMoveInfo(connections: connections, targetGroupId: group.id) + item.image = folderImage + return item + } else { + let submenu = NSMenu() + + let selfItem = NSMenuItem( + title: group.name, + action: #selector(contextMenuBulkMoveToGroup(_:)), + keyEquivalent: "" + ) + selfItem.target = self + selfItem.representedObject = BulkMoveInfo(connections: connections, targetGroupId: group.id) + selfItem.image = folderImage + submenu.addItem(selfItem) + + submenu.addItem(.separator()) + + for child in childGroups { + submenu.addItem(bulkMoveToGroupMenuItem(for: connections, group: child)) + } + + let item = NSMenuItem(title: group.name, action: nil, keyEquivalent: "") + item.submenu = submenu + item.image = folderImage + return item + } + } } } @@ -1343,6 +1557,28 @@ private final class ConnectionMoveInfo: NSObject { } } +/// Helper for bulk delete via NSMenuItem.representedObject +private final class BulkDeleteInfo: NSObject { + let connections: [DatabaseConnection] + let groups: [ConnectionGroup] + + init(connections: [DatabaseConnection], groups: [ConnectionGroup]) { + self.connections = connections + self.groups = groups + } +} + +/// Helper for bulk move via NSMenuItem.representedObject +private final class BulkMoveInfo: NSObject { + let connections: [DatabaseConnection] + let targetGroupId: UUID? + + init(connections: [DatabaseConnection], targetGroupId: UUID?) { + self.connections = connections + self.targetGroupId = targetGroupId + } +} + // MARK: - NSMenuItem Destructive Style private extension NSMenuItem { diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index ba735ebf..e7231b92 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -20,17 +20,17 @@ struct WelcomeWindowView: View { @State private var connections: [DatabaseConnection] = [] @State private var searchText = "" - @State private var connectionToDelete: DatabaseConnection? - @State private var showDeleteConfirmation = false - @State private var selectedConnectionId: UUID? @State private var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() // Group state @State private var groups: [ConnectionGroup] = [] @State private var expandedGroups: Set = [] @State private var groupFormContext: GroupFormContext? - @State private var groupToDelete: ConnectionGroup? - @State private var showDeleteGroupConfirmation = false + + // Delete confirmation (2-step for non-empty groups) + @State private var pendingDelete = PendingDelete() + @State private var showDeleteStep1 = false + @State private var showDeleteStep2 = false @Environment(\.openWindow) private var openWindow @@ -67,35 +67,43 @@ struct WelcomeWindowView: View { .onAppear { loadConnections() } - .confirmationDialog( - "Delete Connection", - isPresented: $showDeleteConfirmation, - presenting: connectionToDelete - ) { connection in - Button("Delete", role: .destructive) { - deleteConnection(connection) - } - Button("Cancel", role: .cancel) {} - } message: { connection in - Text("Are you sure you want to delete \"\(connection.name)\"?") - } .onReceive(NotificationCenter.default.publisher(for: .newConnection)) { _ in openWindow(id: "connection-form", value: nil as UUID?) } .onReceive(NotificationCenter.default.publisher(for: .connectionUpdated)) { _ in loadConnections() } + // Step 1: confirm deletion .confirmationDialog( - "Delete Group", - isPresented: $showDeleteGroupConfirmation, - presenting: groupToDelete - ) { group in - Button("Delete", role: .destructive) { - deleteGroup(group) + pendingDelete.step1Title, + isPresented: $showDeleteStep1 + ) { + Button(pendingDelete.step1ButtonTitle, role: .destructive) { + if pendingDelete.affectedConnectionCount > 0 { + showDeleteStep2 = true + } else { + executePendingDelete() + } } - Button("Cancel", role: .cancel) {} - } message: { group in - Text("Are you sure you want to delete \"\(group.name)\"? Connections will be ungrouped.") + Button("Cancel", role: .cancel) { + pendingDelete = PendingDelete() + } + } message: { + Text(pendingDelete.step1Message) + } + // Step 2: confirm connection deletion (only when groups contain connections) + .confirmationDialog( + pendingDelete.step2Title, + isPresented: $showDeleteStep2 + ) { + Button(pendingDelete.step2ButtonTitle, role: .destructive) { + executePendingDelete() + } + Button("Cancel", role: .cancel) { + pendingDelete = PendingDelete() + } + } message: { + Text(pendingDelete.step2Message) } .sheet(item: $groupFormContext) { context in ConnectionGroupFormSheet( @@ -263,11 +271,7 @@ struct WelcomeWindowView: View { groups: groups, connections: searchText.isEmpty ? connections : filteredConnections, expandedGroupIds: expandedGroups, - selectedItemId: selectedConnectionId, searchText: searchText, - onSelectionChanged: { id in - selectedConnectionId = id - }, onDoubleClickConnection: { connection in connectToDatabase(connection) }, @@ -318,10 +322,6 @@ struct WelcomeWindowView: View { onEditGroup: { group in groupFormContext = GroupFormContext(group: group, parentGroupId: group.parentGroupId) }, - onDeleteGroup: { group in - groupToDelete = group - showDeleteGroupConfirmation = true - }, onEditConnection: { connection in openWindow(id: "connection-form", value: connection.id as UUID?) focusConnectionFormWindow() @@ -339,12 +339,16 @@ struct WelcomeWindowView: View { } } }, - onDeleteConnection: { connection in - connectionToDelete = connection - showDeleteConfirmation = true - }, onMoveConnectionToGroup: { connection, groupId in moveConnectionToGroup(connection, groupId: groupId) + }, + onRequestDelete: { grps, conns in + requestDelete(groups: grps, connections: conns) + }, + onMoveConnectionsToGroup: { conns, groupId in + for conn in conns { + moveConnectionToGroup(conn, groupId: groupId) + } } ) } @@ -418,11 +422,10 @@ struct WelcomeWindowView: View { } private func deleteConnection(_ connection: DatabaseConnection) { - connections.removeAll { $0.id == connection.id } storage.deleteConnection(connection) - storage.saveConnections(connections) } + private func duplicateConnection(_ connection: DatabaseConnection) { // Create duplicate with new UUID and copy passwords let duplicate = storage.duplicateConnection(connection) @@ -467,7 +470,6 @@ struct WelcomeWindowView: View { groupStorage.deleteGroup(group) expandedGroups.subtract(allDeletedIds) groupStorage.saveExpandedGroupIds(expandedGroups) - loadConnections() } private func moveConnectionToGroup(_ connection: DatabaseConnection, groupId: UUID?) { @@ -487,6 +489,43 @@ struct WelcomeWindowView: View { return result } + private func requestDelete(groups: [ConnectionGroup] = [], connections: [DatabaseConnection] = []) { + // Collect all group IDs being deleted (including descendants) + let allGroups = groupStorage.loadGroups() + var deletedGroupIds = Set() + for group in groups { + deletedGroupIds.insert(group.id) + deletedGroupIds.formUnion(collectDescendantIds(of: group.id, in: allGroups)) + } + + // Collect all affected connections: explicitly selected + inside deleted groups + let allConnections = storage.loadConnections() + var affectedIds = Set(connections.map(\.id)) + for conn in allConnections { + if let gid = conn.groupId, deletedGroupIds.contains(gid) { + affectedIds.insert(conn.id) + } + } + + pendingDelete = PendingDelete( + groups: groups, + connections: connections, + affectedConnectionCount: affectedIds.count + ) + showDeleteStep1 = true + } + + private func executePendingDelete() { + for conn in pendingDelete.connections { + deleteConnection(conn) + } + for group in pendingDelete.groups { + deleteGroup(group) + } + pendingDelete = PendingDelete() + loadConnections() + } + private func toggleGroup(_ groupId: UUID) { if expandedGroups.contains(groupId) { expandedGroups.remove(groupId) @@ -497,6 +536,77 @@ struct WelcomeWindowView: View { } } +// MARK: - PendingDelete + +/// Tracks items pending deletion with 2-step confirmation +private struct PendingDelete { + var groups: [ConnectionGroup] = [] + var connections: [DatabaseConnection] = [] + var affectedConnectionCount = 0 + + var step1Title: String { + if !groups.isEmpty, connections.isEmpty { + return groups.count == 1 + ? String(localized: "Delete Group") + : String(localized: "Delete Groups") + } + return connections.count == 1 + ? String(localized: "Delete Connection") + : String(localized: "Delete Connections") + } + + var step1Message: String { + if groups.count == 1, connections.isEmpty { + let name = groups[0].name + if affectedConnectionCount > 0 { + return String( + localized: + "Delete \"\(name)\" and its \(affectedConnectionCount) connection(s)?" + ) + } + return String(localized: "Delete \"\(name)\"?") + } + if !groups.isEmpty, !connections.isEmpty { + return String( + localized: + "Delete \(groups.count) group(s) and \(affectedConnectionCount) connection(s) total?" + ) + } + if groups.count > 1 { + if affectedConnectionCount > 0 { + return String( + localized: + "Delete \(groups.count) groups and \(affectedConnectionCount) connection(s) inside?" + ) + } + return String(localized: "Delete \(groups.count) groups?") + } + if connections.count == 1 { + return String(localized: "Delete \"\(connections[0].name)\"?") + } + return String(localized: "Delete \(connections.count) connections?") + } + + var step1ButtonTitle: String { + String(localized: "Delete") + } + + var step2Title: String { + String(localized: "Delete Connections") + } + + var step2Message: String { + String( + localized: + "\(affectedConnectionCount) connection(s) will be permanently deleted. This cannot be undone." + ) + } + + var step2ButtonTitle: String { + String(localized: "Delete \(affectedConnectionCount) Connection(s)") + } +} + // MARK: - GroupFormContext /// Identifiable wrapper so `.sheet(item:)` creates fresh content with correct values From 1e9bb1d7b3e19265deecf179cb1d612f10c981e6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Mar 2026 13:01:46 +0700 Subject: [PATCH 10/16] docs: add dedicated download page with direct DMG links --- docs/docs.json | 5 ++-- docs/download.mdx | 55 ++++++++++++++++++++++++++++++++++++++++++++ docs/vi/download.mdx | 55 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 docs/download.mdx create mode 100644 docs/vi/download.mdx diff --git a/docs/docs.json b/docs/docs.json index be3d1b62..13c215ab 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -26,7 +26,7 @@ "groups": [ { "group": "Getting Started", - "pages": ["index", "quickstart", "installation", "changelog"] + "pages": ["index", "quickstart", "installation", "download", "changelog"] }, { "group": "Database Connections", @@ -113,6 +113,7 @@ "vi/index", "vi/quickstart", "vi/installation", + "vi/download", "vi/changelog" ] }, @@ -233,7 +234,7 @@ "primary": { "type": "button", "label": "Download", - "href": "https://github.com/datlechin/tablepro/releases" + "href": "/download" } }, "contextual": { diff --git a/docs/download.mdx b/docs/download.mdx new file mode 100644 index 00000000..9bded182 --- /dev/null +++ b/docs/download.mdx @@ -0,0 +1,55 @@ +--- +title: Download TablePro +description: Download TablePro for your Mac +--- + +# Download TablePro + +## Install via Homebrew + +The fastest way to install TablePro: + +```bash +brew install datlechin/tap/tablepro +``` + + +Homebrew automatically removes the macOS quarantine attribute, so you won't need to run `xattr -cr` on first launch. + + +## Direct Download + +Download the latest version for your Mac's architecture: + + + + For Macs with M1, M2, M3, M4, or M5 chip. Download `TablePro-arm64.dmg`. + + + For Macs with Intel processor. Download `TablePro-x86_64.dmg`. + + + +### Which version do I need? + +Click the **Apple menu** () > **About This Mac**: + +- **Chip: Apple M1/M2/M3/M4/M5** — download Apple Silicon +- **Processor: Intel** — download Intel + +### After downloading + +1. Open the `.dmg` file and drag TablePro to Applications +2. On first launch, macOS will block the app. Open **Terminal** and run: + +```bash +xattr -cr /Applications/TablePro.app +``` + +3. Open TablePro again — you only need to do this once + +See the [Installation guide](/installation#first-launch-security) for more details. + +## All Releases + +Browse all versions on the [GitHub Releases page](https://github.com/datlechin/tablepro/releases). diff --git a/docs/vi/download.mdx b/docs/vi/download.mdx new file mode 100644 index 00000000..5afb8134 --- /dev/null +++ b/docs/vi/download.mdx @@ -0,0 +1,55 @@ +--- +title: Tải TablePro +description: Tải TablePro cho máy Mac của bạn +--- + +# Tải TablePro + +## Cài đặt qua Homebrew + +Cách nhanh nhất để cài đặt TablePro: + +```bash +brew install datlechin/tap/tablepro +``` + + +Homebrew tự động gỡ bỏ thuộc tính quarantine của macOS, nên bạn không cần chạy `xattr -cr` khi khởi chạy lần đầu. + + +## Tải trực tiếp + +Tải phiên bản mới nhất cho kiến trúc máy Mac của bạn: + + + + Dành cho máy Mac có chip M1, M2, M3, M4 hoặc M5. Tải `TablePro-arm64.dmg`. + + + Dành cho máy Mac có bộ xử lý Intel. Tải `TablePro-x86_64.dmg`. + + + +### Tôi cần phiên bản nào? + +Nhấp vào **menu Apple** () > **Giới thiệu về máy Mac này**: + +- **Chip: Apple M1/M2/M3/M4/M5** — tải Apple Silicon +- **Bộ xử lý: Intel** — tải Intel + +### Sau khi tải + +1. Mở file `.dmg` và kéo TablePro vào thư mục Applications +2. Khi khởi chạy lần đầu, macOS sẽ chặn ứng dụng. Mở **Terminal** và chạy: + +```bash +xattr -cr /Applications/TablePro.app +``` + +3. Mở lại TablePro — bạn chỉ cần làm điều này một lần + +Xem [Hướng dẫn cài đặt](/vi/installation#bảo-mật-khi-khởi-chạy-lần-đầu) để biết thêm chi tiết. + +## Tất cả phiên bản + +Xem tất cả phiên bản trên [trang GitHub Releases](https://github.com/datlechin/tablepro/releases). From 334b5faab086b2b9323ea879d081360cea3239c3 Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:01:46 +0700 Subject: [PATCH 11/16] fix: reset pbxproj to main and regenerate Localizable.xcstrings --- TablePro.xcodeproj/project.pbxproj | 4 +- TablePro/Resources/Localizable.xcstrings | 12139 +++++++++++---------- 2 files changed, 6133 insertions(+), 6010 deletions(-) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 69430083..393ab66e 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -379,7 +379,7 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 26; DEAD_CODE_STRIPPING = YES; @@ -472,7 +472,7 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = TablePro/TablePro.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = YES; CURRENT_PROJECT_VERSION = 26; diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 113fc559..8b4c5d89 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1,9854 +1,9977 @@ { - "sourceLanguage": "en", - "strings": { - "": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "" + "sourceLanguage" : "en", + "strings" : { + "" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "" } } } }, - "%@ (%lld/%lld)": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$@ (%2$lld/%3$lld)" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$@ (%2$lld/%3$lld)" + "--" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "--" } } } }, - "%@ cannot be empty": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%@ không được để trống" + "—" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "—" } } } }, - "%@ cannot be negative": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%@ không được là số âm" + ".%@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : ".%@" } } } }, - "%@ is already assigned to \"%@\". Reassigning will remove it from that action.": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$@ is already assigned to \"%2$@\". Reassigning will remove it from that action." + "·" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "·" } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$@ đã được gán cho \"%2$@\". Gán lại sẽ xóa phím tắt khỏi hành động đó." + } + } + }, + "''" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "''" } } } }, - "%@ ms": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%@ ms" + "(%@)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" } } } }, - "%@ must be %lld characters or less": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$@ must be %2$lld characters or less" + "(%lld %@)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "(%1$lld %2$@)" } }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$@ phải có %2$lld ký tự trở xuống" + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%1$lld %2$@)" } } } }, - "%@ must be between %@ and %@": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$@ must be between %2$@ and %3$@" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$@ phải nằm trong khoảng %2$@ đến %3$@" + "(%lld active)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%lld đang hoạt động)" } } } }, - "%@ rows": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%@ dòng" + "(%lld)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%lld)" } } } }, - "%@ s": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%@ giây" + "(optional)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "(tùy chọn)" } } } }, - "%@ seconds": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%@ giây" + "/path/to/ca-cert.pem" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "/đường/dẫn/tới/ca-cert.pem" } } } }, - "%@.": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%@." + "/path/to/database.sqlite" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "/đường/dẫn/tới/database.sqlite" } } } }, - "%@/%@ rows": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$@/%2$@ rows" + "%@ (%lld/%lld)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ (%2$lld/%3$lld)" } }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$@/%2$@ dòng" + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ (%2$lld/%3$lld)" } } } }, - "%@ms": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%@ms" + "%@ cannot be empty" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ không được để trống" } } } }, - "%@s": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%@s" + "%@ cannot be negative" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ không được là số âm" } } } }, - "%lld": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%lld" + "%@ is already assigned to \"%@\". Reassigning will remove it from that action." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ is already assigned to \"%2$@\". Reassigning will remove it from that action." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ đã được gán cho \"%2$@\". Gán lại sẽ xóa phím tắt khỏi hành động đó." } } } }, - "%lld in / %lld out tokens": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld in / %2$lld out tokens" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$lld vào / %2$lld ra token" + "%@ ms" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ms" } } } }, - "%lld in · %lld out": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld in · %2$lld out" + "%@ must be %lld characters or less" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ must be %2$lld characters or less" } }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%lld vào · %lld ra" + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ phải có %2$lld ký tự trở xuống" } } } }, - "%lld of %lld": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld of %2$lld" + "%@ must be between %@ and %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ must be between %2$@ and %3$@" } }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$lld / %2$lld" + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ phải nằm trong khoảng %2$@ đến %3$@" } } } }, - "%lld of %lld rows selected": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld of %2$lld rows selected" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã chọn %1$lld trong %2$lld dòng" + "%@ rows" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dòng" } } } }, - "%lld pt": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%lld pt" + "%@ s" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ giây" } } } }, - "%lld row%@ affected": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld row%2$@ affected" + "%@ seconds" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ giây" } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%lld dòng%@ bị ảnh hưởng" + } + } + }, + "%@." : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." } } } }, - "%lld seconds": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%lld giây" + "%@/%@ rows" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@/%2$@ rows" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/%2$@ dòng" } } } }, - "%lld skipped (no options)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%lld bị bỏ qua (không có tùy chọn)" + "%@ms" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ms" } } } }, - "%lld statements": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%lld câu lệnh" + "%@s" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@s" } } } }, - "%lld statements executed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã thực thi %lld câu lệnh" + "%lld" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" } } } }, - "%lld table%@ to export": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld table%2$@ to export" + "%lld connection(s) will be permanently deleted. This cannot be undone." : { + "comment" : "A message in the second step of a confirmation dialog that informs the user about the number of affected connections and that it cannot be undone. The argument is the number of affected connections.", + "isCommentAutoGenerated" : true + }, + "%lld in · %lld out" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld in · %2$lld out" } }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%lld bảng%@ để xuất" + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld vào · %lld ra" } } } }, - "%lld tables": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "%lld bảng" + "%lld in / %lld out tokens" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld in / %2$lld out tokens" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld vào / %2$lld ra token" } } } }, - "%lld-%lld of %@ rows": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld-%2$lld of %3$@ rows" + "%lld of %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld of %2$lld" } }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$lld-%2$lld trong %3$@ dòng" + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld / %2$lld" } } } }, - "%lld-%lld of %@%@ rows": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld-%2$lld of %3$@%4$@ rows" + "%lld of %lld rows selected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld of %2$lld rows selected" } }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$lld-%2$lld của %3$@%4$@ dòng" + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã chọn %1$lld trong %2$lld dòng" + } + } + } + }, + "%lld pt" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pt" } } } }, - "%lldm %llds": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lldm %2$llds" + "%lld row%@ affected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld row%2$@ affected" } }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%lldm %llds" + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dòng%@ bị ảnh hưởng" } } } }, - "''": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "''" + "%lld seconds" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld giây" } } } }, - "(%@)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "(%@)" + "%lld skipped (no options)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld bị bỏ qua (không có tùy chọn)" } } } }, - "(%lld %@)": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "(%1$lld %2$@)" + "%lld statements" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld câu lệnh" } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "(%1$lld %2$@)" + } + } + }, + "%lld statements executed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã thực thi %lld câu lệnh" } } } }, - "(%lld active)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "(%lld đang hoạt động)" + "%lld table%@ to export" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld table%2$@ to export" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld bảng%@ để xuất" } } } }, - "(%lld)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "(%lld)" + "%lld tables" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld bảng" } } } }, - "(optional)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "(tùy chọn)" + "%lld-%lld of %@ rows" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld-%2$lld of %3$@ rows" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld-%2$lld trong %3$@ dòng" } } } }, - "--": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "--" + "%lld-%lld of %@%@ rows" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld-%2$lld of %3$@%4$@ rows" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld-%2$lld của %3$@%4$@ dòng" } } } }, - ".%@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": ".%@" + "%lldm %llds" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lldm %2$llds" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldm %llds" } } } }, - "/path/to/ca-cert.pem": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "/đường/dẫn/tới/ca-cert.pem" + "•" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "•" } } } }, - "/path/to/database.sqlite": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "/đường/dẫn/tới/database.sqlite" + "••••••••" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "••••••••" } } } }, - "0": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "0" + "© 2026 Ngo Quoc Dat.\n%@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "© 2026 Ngo Quoc Dat.\n%@" } } } }, - "1": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "1" + "<1ms" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "<1ms" } } } }, - "1 (no batching)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "1 (không gom nhóm)" + "=" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "=" } } } }, - "1 year": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "1 năm" + "~/.ssh/id_rsa" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "~/.ssh/id_rsa" } } } }, - "1,000": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "1,000" + "⌘K" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "⌘K" } } } }, - "1,000 rows": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "1.000 dòng" + "⌘T" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "⌘T" } } } }, - "10,000": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "10,000" + "0" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" } } } }, - "10,000 rows": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "10.000 dòng" + "1" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "1" } } } }, - "100": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "100" + "1 (no batching)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 (không gom nhóm)" } } } }, - "100 rows": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "100 dòng" + "1 year" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 năm" } } } }, - "2": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "2" + "1,000" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "1,000" } } } }, - "2 spaces": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "2 dấu cách" + "1,000 rows" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "1.000 dòng" } } } }, - "22": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "22" + "2" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "2" } } } }, - "3": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "3" + "2 spaces" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "2 dấu cách" } } } }, - "30 days": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "30 ngày" + "3" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "3" } } } }, - "4 spaces": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "4 dấu cách" + "4 spaces" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "4 dấu cách" } } } }, - "5,000": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "5,000" + "5,000" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "5,000" } } } }, - "5,000 rows": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "5.000 dòng" + "5,000 rows" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "5.000 dòng" } } } }, - "500": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "500" + "7 days" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 ngày" } } } }, - "500 rows": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "500 dòng" + "8 spaces" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "8 dấu cách" } } } }, - "7 days": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "7 ngày" + "10,000" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "10,000" } } } }, - "8 spaces": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "8 dấu cách" + "10,000 rows" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "10.000 dòng" } } } }, - "90 days": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "90 ngày" + "22" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "22" } } } }, - "<1ms": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "<1ms" + "30 days" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 ngày" } } } }, - "=": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "=" + "90 days" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "90 ngày" } } } }, - "A fast, lightweight native macOS database client": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Ứng dụng quản lý cơ sở dữ liệu gốc macOS nhanh và nhẹ" + "100" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" } } } }, - "ACTIVE CONNECTIONS": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "KẾT NỐI ĐANG HOẠT ĐỘNG" + "100 rows" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "100 dòng" } } } }, - "AI": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "AI" + "500" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "500" } } } }, - "AI Chat": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "AI Chat" + "500 rows" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "500 dòng" } } } }, - "AI Policy": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chính sách AI" + "A fast, lightweight native macOS database client" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ứng dụng quản lý cơ sở dữ liệu gốc macOS nhanh và nhẹ" } } } }, - "AI is disabled for this connection.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "AI bị tắt cho kết nối này." + "About TablePro" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giới thiệu TablePro" } } } }, - "AI-powered SQL completions appear as ghost text while typing. Press Tab to accept, Escape to dismiss.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Gợi ý SQL bằng AI xuất hiện dưới dạng văn bản mờ khi gõ. Nhấn Tab để chấp nhận, Escape để bỏ qua." + "Accent Color:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Màu nhấn:" } } } }, - "ALL DATABASES": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "TẤT CẢ CƠ SỞ DỮ LIỆU" + "Activate" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kích hoạt" } } } }, - "ALL SCHEMAS": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "TẤT CẢ SCHEMA" + "Activation Failed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kích hoạt thất bại" } } } }, - "AND": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "AND" + "Active" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang hoạt động" } } } }, - "API Key": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "API Key" + "Active Connections" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối đang hoạt động" } } } }, - "AUTO": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "TỰ ĐỘNG" + "ACTIVE CONNECTIONS" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "KẾT NỐI ĐANG HOẠT ĐỘNG" } } } }, - "About TablePro": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giới thiệu TablePro" + "Add Check Constraint" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm ràng buộc kiểm tra" } } } }, - "Accent Color:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Màu nhấn:" + "Add Column" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm cột" } } } }, - "Activate": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kích hoạt" + "Add columns first" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm cột trước" } } } }, - "Activation Failed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kích hoạt thất bại" + "Add filter" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm bộ lọc" } } } }, - "Active": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang hoạt động" + "Add Filter (Cmd+Shift+F)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm bộ lọc (Cmd+Shift+F)" } } } }, - "Active Connections": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối đang hoạt động" + "Add Foreign Key" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm khóa ngoại" } } } }, - "Add Check Constraint": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm ràng buộc kiểm tra" + "Add Index" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm chỉ mục" } } } }, - "Add Column": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm cột" + "Add indexes to improve query performance on frequently searched columns" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm chỉ mục để cải thiện hiệu suất truy vấn trên các cột thường xuyên tìm kiếm" } } } }, - "Add Filter (Cmd+Shift+F)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm bộ lọc (Cmd+Shift+F)" + "Add Provider" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm nhà cung cấp" } } } }, - "Add Foreign Key": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm khóa ngoại" + "Add Row" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm dòng" } } } }, - "Add Index": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm chỉ mục" + "Add validation rules to ensure data integrity" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm quy tắc xác thực để đảm bảo tính toàn vẹn dữ liệu" } } } }, - "Add Provider": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm nhà cung cấp" + "AI" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI" } } } }, - "Add Row": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm dòng" + "AI Chat" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI Chat" } } } }, - "Add columns first": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm cột trước" + "AI is disabled for this connection." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI bị tắt cho kết nối này." } } } }, - "Add filter": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm bộ lọc" + "AI Policy" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chính sách AI" } } } }, - "Add indexes to improve query performance on frequently searched columns": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm chỉ mục để cải thiện hiệu suất truy vấn trên các cột thường xuyên tìm kiếm" + "AI-powered SQL completions appear as ghost text while typing. Press Tab to accept, Escape to dismiss." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gợi ý SQL bằng AI xuất hiện dưới dạng văn bản mờ khi gõ. Nhấn Tab để chấp nhận, Escape để bỏ qua." } } } }, - "Add validation rules to ensure data integrity": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thêm quy tắc xác thực để đảm bảo tính toàn vẹn dữ liệu" + "All %lld rows selected" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã chọn tất cả %lld dòng" } } } }, - "All %lld rows selected": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã chọn tất cả %lld dòng" + "All columns" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tất cả cột" } } } }, - "All Time": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tất cả" + "ALL DATABASES" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TẤT CẢ CƠ SỞ DỮ LIỆU" } } } }, - "All columns": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tất cả cột" + "All rights reserved." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã đăng ký bản quyền." } } } }, - "All rights reserved.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã đăng ký bản quyền." + "ALL SCHEMAS" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TẤT CẢ SCHEMA" } } } }, - "Allow": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cho phép" + "All Time" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tất cả" } } } }, - "Allow AI Access": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cho phép truy cập AI" + "Allow" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cho phép" } } } }, - "Always": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Luôn luôn" + "Allow AI Access" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cho phép truy cập AI" } } } }, - "Always Allow": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Luôn cho phép" + "Always" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luôn luôn" } } } }, - "Appearance": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giao diện" + "Always Allow" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luôn cho phép" } } } }, - "Appearance:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giao diện:" + "and" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "và" } } } }, - "Apply All": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Áp dụng tất cả" + "AND" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "AND" } } } }, - "Apply Changes": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Áp dụng thay đổi" + "API Key" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" } } } }, - "Apply This Filter": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Áp dụng bộ lọc này" + "Appearance" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giao diện" } } } }, - "Apply this filter": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Áp dụng bộ lọc này" + "Appearance:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giao diện:" } } } }, - "Are you sure you want to delete \"%@\"?": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bạn có chắc muốn xóa \"%@\" không?" + "Apply All" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Áp dụng tất cả" } } } }, - "Are you sure you want to disconnect from this database?": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bạn có chắc muốn ngắt kết nối khỏi cơ sở dữ liệu này không?" + "Apply Changes" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Áp dụng thay đổi" } } } }, - "Ask AI about your database": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hỏi AI về cơ sở dữ liệu của bạn" + "Apply this filter" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Áp dụng bộ lọc này" } } } }, - "Ask AI to Fix": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhờ AI sửa lỗi" + "Apply This Filter" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Áp dụng bộ lọc này" } } } }, - "Ask Each Time": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hỏi mỗi lần" + "Are you sure you want to delete \"%@\"?" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn có chắc muốn xóa \"%@\" không?" } } } }, - "Ask about your database...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hỏi về cơ sở dữ liệu của bạn..." + "Are you sure you want to disconnect from this database?" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn có chắc muốn ngắt kết nối khỏi cơ sở dữ liệu này không?" } } } }, - "Authentication": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xác thực" + "Ask about your database..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hỏi về cơ sở dữ liệu của bạn..." } } } }, - "Authentication failed. Check your API key.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xác thực thất bại. Kiểm tra API key của bạn." + "Ask AI about your database" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hỏi AI về cơ sở dữ liệu của bạn" } } } }, - "Authentication failed: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xác thực thất bại: %@" + "Ask AI to Fix" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhờ AI sửa lỗi" } } } }, - "Auto Inc": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tự tăng" + "Ask Each Time" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hỏi mỗi lần" } } } }, - "Auto Increment": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tự động tăng" + "Authentication" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xác thực" } } } }, - "Auto cleanup on startup": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tự động dọn dẹp khi khởi động" + "Authentication failed: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xác thực thất bại: %@" } } } }, - "Auto-indent": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tự động thụt lề" + "Authentication failed. Check your API key." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xác thực thất bại. Kiểm tra API key của bạn." } } } }, - "Auto-show inspector on row select": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tự động hiện thanh kiểm tra khi chọn dòng" + "AUTO" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TỰ ĐỘNG" } } } }, - "Automatically check for updates": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tự động kiểm tra cập nhật" + "Auto cleanup on startup" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự động dọn dẹp khi khởi động" } } } }, - "Avg Row": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "TB dòng" + "Auto Inc" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự tăng" } } } }, - "Blue": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xanh dương" + "Auto Increment" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự động tăng" } } } }, - "Browse": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Duyệt" + "Auto-indent" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự động thụt lề" } } } }, - "Browse...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Duyệt..." + "Auto-show inspector on row select" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự động hiện thanh kiểm tra khi chọn dòng" } } } }, - "CA Cert": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chứng chỉ CA" + "Automatically check for updates" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự động kiểm tra cập nhật" } } } }, - "CA Certificate": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chứng chỉ CA" + "Avg Row" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TB dòng" } } } }, - "CMD": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "CMD" + "between" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "giữa" } } } }, - "CRITICAL: Transaction rollback failed - database may be in inconsistent state: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "NGHIÊM TRỌNG: Hoàn tác giao dịch thất bại - cơ sở dữ liệu có thể ở trạng thái không nhất quán: %@" + "Blue" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xanh dương" } } } }, - "CURDATE()": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "CURDATE()" + "Browse" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyệt" } } } }, - "CURRENT_TIMESTAMP()": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "CURRENT_TIMESTAMP()" + "Browse..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyệt..." } } } }, - "CURTIME()": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "CURTIME()" + "CA Cert" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chứng chỉ CA" } } } }, - "Cancel": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hủy" + "CA Certificate" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chứng chỉ CA" } } } }, - "Cannot format empty SQL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không thể định dạng SQL trống" + "Cancel" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy" } } } }, - "Cascade": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cascade" + "Cannot format empty SQL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể định dạng SQL trống" } } } }, - "Change Color": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đổi màu" + "Cascade" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cascade" } } } }, - "Change File": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đổi tệp" + "Change Color" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đổi màu" } } } }, - "Change File...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đổi tệp..." + "Change File" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đổi tệp" } } } }, - "Character Set": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bộ ký tự" + "Change File..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đổi tệp..." } } } }, - "Charset (e.g., utf8mb4)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bộ ký tự (vd: utf8mb4)" + "Character Set" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bộ ký tự" } } } }, - "Chat": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chat" + "Charset (e.g., utf8mb4)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bộ ký tự (vd: utf8mb4)" } } } }, - "Check for Updates...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kiểm tra cập nhật..." + "Chat" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chat" } } } }, - "Choose a query from the list\nto see its full content here.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chọn một truy vấn từ danh sách\nđể xem nội dung đầy đủ tại đây." + "Check for Updates..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm tra cập nhật..." } } } }, - "Clear": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa" + "Choose a query from the list\nto see its full content here." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn một truy vấn từ danh sách\nđể xem nội dung đầy đủ tại đây." } } } }, - "Clear All": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa tất cả" + "Clear" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa" } } } }, - "Clear All History?": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa toàn bộ lịch sử?" + "Clear All" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa tất cả" } } } }, - "Clear Conversation": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa hội thoại" + "Clear all history" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xoá toàn bộ lịch sử" } } } }, - "Clear History...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa lịch sử..." + "Clear All History?" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa toàn bộ lịch sử?" } } } }, - "Clear Query (⌘+Delete)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa truy vấn (⌘+Delete)" + "Clear all query history" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa toàn bộ lịch sử truy vấn" } } } }, - "Clear Recents": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xoá gần đây" + "Clear Conversation" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa hội thoại" } } } }, - "Clear Search": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa tìm kiếm" + "Clear History..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa lịch sử..." } } } }, - "Clear Selection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bỏ chọn" + "Clear Query (⌘+Delete)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa truy vấn (⌘+Delete)" } } } }, - "Clear all history": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xoá toàn bộ lịch sử" + "Clear Recents" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xoá gần đây" } } } }, - "Clear all query history": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa toàn bộ lịch sử truy vấn" + "Clear search" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa tìm kiếm" } } } }, - "Clear search": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa tìm kiếm" + "Clear Search" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa tìm kiếm" } } } }, - "Clear table filter": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa bộ lọc bảng" + "Clear Selection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bỏ chọn" } } } }, - "Click + to add a relationship between this table and another": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhấn + để thêm mối quan hệ giữa bảng này và bảng khác" + "Clear table filter" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa bộ lọc bảng" } } } }, - "Click + to create your first connection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhấn + để tạo kết nối đầu tiên" + "Click + to add a relationship between this table and another" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhấn + để thêm mối quan hệ giữa bảng này và bảng khác" } } } }, - "Click a table": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhấn vào một bảng" + "Click + to create your first connection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhấn + để tạo kết nối đầu tiên" } } } }, - "Click to load models": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhấp để tải danh sách model" + "Click a table" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhấn vào một bảng" } } } }, - "Click to show all tables with metadata": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhấn để hiện tất cả bảng với siêu dữ liệu" + "Click to load models" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhấp để tải danh sách model" } } } }, - "Client Cert": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chứng chỉ máy khách" + "Click to show all tables with metadata" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhấn để hiện tất cả bảng với siêu dữ liệu" } } } }, - "Client Certificates (Optional)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chứng chỉ máy khách (Tùy chọn)" + "Client Cert" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chứng chỉ máy khách" } } } }, - "Client Key": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Khóa máy khách" + "Client Certificates (Optional)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chứng chỉ máy khách (Tùy chọn)" } } } }, - "Clipboard is empty or contains no text data.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bộ nhớ tạm trống hoặc không chứa dữ liệu văn bản." + "Client Key" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khóa máy khách" } } } }, - "Close": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đóng" + "Clipboard is empty or contains no text data." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bộ nhớ tạm trống hoặc không chứa dữ liệu văn bản." } } } }, - "Close (ESC)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đóng (ESC)" + "Close" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đóng" } } } }, - "Close Tab": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đóng tab" + "Close (ESC)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đóng (ESC)" } } } }, - "Closing this tab will discard all unsaved changes.": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đóng tab này sẽ hủy tất cả thay đổi chưa lưu." + "Close Tab" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đóng tab" } } } }, - "Collation": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đối chiếu" + "Closing this tab will discard all unsaved changes." : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đóng tab này sẽ hủy tất cả thay đổi chưa lưu." } } } }, - "Color": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Màu sắc" + "CMD" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CMD" } } } }, - "Column": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cột" + "Collation" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đối chiếu" } } } }, - "Column Details": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chi tiết cột" + "Color" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Màu sắc" } } } }, - "Column Name": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên cột" + "Column" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cột" } } } }, - "Column count mismatch on line %lld: expected %lld columns, found %lld.": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "Column count mismatch on line %1$lld: expected %2$lld columns, found %3$lld." + "Column count mismatch on line %lld: expected %lld columns, found %lld." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Column count mismatch on line %1$lld: expected %2$lld columns, found %3$lld." } }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Số cột không khớp ở dòng %1$lld: mong đợi %2$lld cột, tìm thấy %3$lld." + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số cột không khớp ở dòng %1$lld: mong đợi %2$lld cột, tìm thấy %3$lld." } } } }, - "Column name": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên cột" + "Column Details" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chi tiết cột" } } } }, - "Column: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cột: %@" + "Column name" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên cột" } } } }, - "Columns": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cột" + "Column Name" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên cột" } } } }, - "Columns (comma-separated)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Các cột (phân tách bằng dấu phẩy)" + "Column: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cột: %@" } } } }, - "Comfortable": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thoải mái" + "Columns" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cột" } } } }, - "Command Preview": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước lệnh" + "Columns (comma-separated)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Các cột (phân tách bằng dấu phẩy)" } } } }, - "Comment": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Ghi chú" + "Comfortable" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thoải mái" } } } }, - "Compact": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thu gọn" + "Command Preview" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước lệnh" } } } }, - "Compress the file using Gzip": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nén tệp bằng Gzip" + "Comment" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ghi chú" } } } }, - "Config Host": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Máy chủ cấu hình" + "Compact" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thu gọn" } } } }, - "Configure an AI provider in Settings to start chatting.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cấu hình nhà cung cấp AI trong Cài đặt để bắt đầu trò chuyện." + "Compress the file using Gzip" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nén tệp bằng Gzip" } } } }, - "Connect": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối" + "Config Host" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Máy chủ cấu hình" } } } }, - "Connected": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã kết nối" + "Configure an AI provider in Settings to start chatting." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cấu hình nhà cung cấp AI trong Cài đặt để bắt đầu trò chuyện." } } } }, - "Connecting": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang kết nối" + "Connect" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối" } } } }, - "Connecting...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang kết nối..." + "Connected" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã kết nối" } } } }, - "Connection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối" + "Connecting" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang kết nối" } } } }, - "Connection Failed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối thất bại" + "Connecting..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang kết nối..." } } } }, - "Connection Not Found": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không tìm thấy kết nối" + "Connection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối" } } } }, - "Connection Status": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Trạng thái kết nối" + "Connection Failed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối thất bại" } } } }, - "Connection Switcher": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển đổi kết nối" + "Connection lost" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mất kết nối" } } } }, - "Connection URL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "URL kết nối" + "Connection name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên kết nối" } } } }, - "Connection URL cannot be empty": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "URL kết nối không được để trống" + "Connection Not Found" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tìm thấy kết nối" } } } }, - "Connection URL must include a host": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "URL kết nối phải bao gồm host" + "Connection Status" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trạng thái kết nối" } } } }, - "Connection lost": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mất kết nối" + "Connection successful" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối thành công" } } } }, - "Connection name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên kết nối" + "Connection Switcher" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển đổi kết nối" } } } }, - "Connection successful": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối thành công" + "Connection test failed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm tra kết nối thất bại" } } } }, - "Connection test failed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kiểm tra kết nối thất bại" + "Connection URL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL kết nối" } } } }, - "Constraint name": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên ràng buộc" + "Connection URL cannot be empty" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL kết nối không được để trống" } } } }, - "Context": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Ngữ cảnh" + "Connection URL must include a host" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL kết nối phải bao gồm host" } } } }, - "Continue": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tiếp tục" + "Constraint name" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên ràng buộc" } } } }, - "Conversation History": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lịch sử hội thoại" + "contains" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chứa" } } } }, - "Convert NULL to EMPTY": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển NULL thành RỖNG" + "Context" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngữ cảnh" } } } }, - "Convert NULL to empty": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển NULL thành rỗng" + "Continue" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiếp tục" } } } }, - "Convert line break to space": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển xuống dòng thành dấu cách" + "Conversation History" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lịch sử hội thoại" } } } }, - "Copied": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã sao chép" + "Convert line break to space" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển xuống dòng thành dấu cách" } } } }, - "Copied!": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã sao chép!" + "Convert NULL to empty" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển NULL thành rỗng" } } } }, - "Copy": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép" + "Convert NULL to EMPTY" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển NULL thành RỖNG" } } } }, - "Copy All": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép tất cả" + "Copied" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã sao chép" } } } }, - "Copy Cell Value": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép giá trị ô" + "Copied!" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã sao chép!" } } } }, - "Copy Column Name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép tên cột" + "Copy" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép" } } } }, - "Copy Name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép tên" + "Copy All" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép tất cả" } } } }, - "Copy Query": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép truy vấn" + "Copy as URL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép dạng URL" } } } }, - "Copy SQL": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép SQL" + "Copy Cell Value" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép giá trị ô" } } } }, - "Copy Value": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép giá trị" + "Copy Column Name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép tên cột" } } } }, - "Copy as URL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép dạng URL" + "Copy Name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép tên" } } } }, - "Copy this statement to clipboard": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép câu lệnh này vào bộ nhớ tạm" + "Copy Query" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép truy vấn" } } } }, - "Copy with Headers": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sao chép kèm tiêu đề" + "Copy SQL" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép SQL" } } } }, - "Count all rows": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đếm tất cả hàng" + "Copy this statement to clipboard" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép câu lệnh này vào bộ nhớ tạm" } } } }, - "Counting...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang đếm..." + "Copy Value" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép giá trị" } } } }, - "Create": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo" + "Copy with Headers" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép kèm tiêu đề" } } } }, - "Create Database": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo cơ sở dữ liệu" + "Count all rows" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đếm tất cả hàng" } } } }, - "Create New Group": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo nhóm mới" + "Counting..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang đếm..." } } } }, - "Create New Group...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo nhóm mới..." + "Create" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo" } } } }, - "Create New Tag": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo thẻ mới" + "Create a connection to get started with\nyour databases." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo kết nối để bắt đầu sử dụng\ncơ sở dữ liệu của bạn." } } } }, - "Create New Tag...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo thẻ mới..." + "Create connection..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo kết nối..." } } } }, - "Create New View...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo view mới..." + "Create Database" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo cơ sở dữ liệu" } } } }, - "Create a connection to get started with\nyour databases.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo kết nối để bắt đầu sử dụng\ncơ sở dữ liệu của bạn." + "Create new database" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo cơ sở dữ liệu mới" } } } }, - "Create connection...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo kết nối..." + "Create New Group" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo nhóm mới" } } } }, - "Create new database": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo cơ sở dữ liệu mới" + "Create New Group..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo nhóm mới..." } } } }, - "Created": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã tạo" + "Create New Tag" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo thẻ mới" } } } }, - "Creating...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang tạo..." + "Create New Tag..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo thẻ mới..." } } } }, - "Current database: %@ (read-only, ⌘K to switch)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cơ sở dữ liệu hiện tại: %@ (chỉ đọc, ⌘K để chuyển)" + "Create New View..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo view mới..." } } } }, - "Current database: %@ (⌘K to switch)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cơ sở dữ liệu hiện tại: %@ (⌘K để chuyển)" + "Created" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã tạo" } } } }, - "Current schema: %@ (read-only, ⌘K to switch)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Schema hiện tại: %@ (chỉ đọc, ⌘K để chuyển)" + "Creating..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang tạo..." } } } }, - "Current schema: %@ (⌘K to switch)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Schema hiện tại: %@ (⌘K để chuyển)" + "CRITICAL: Transaction rollback failed - database may be in inconsistent state: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "NGHIÊM TRỌNG: Hoàn tác giao dịch thất bại - cơ sở dữ liệu có thể ở trạng thái không nhất quán: %@" } } } }, - "Cursor position %lld exceeds SQL length (%lld)": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "Cursor position %1$lld exceeds SQL length (%2$lld)" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Vị trí con trỏ %1$lld vượt quá độ dài SQL (%2$lld)" + "CURDATE()" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CURDATE()" } } } }, - "Custom": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tùy chỉnh" + "current" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "hiện tại" } } } }, - "Cut": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cắt" + "Current database: %@ (⌘K to switch)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cơ sở dữ liệu hiện tại: %@ (⌘K để chuyển)" } } } }, - "DEFAULT": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "MẶC ĐỊNH" + "Current database: %@ (read-only, ⌘K to switch)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cơ sở dữ liệu hiện tại: %@ (chỉ đọc, ⌘K để chuyển)" } } } }, - "Dark": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tối" + "Current schema: %@ (⌘K to switch)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schema hiện tại: %@ (⌘K để chuyển)" } } } }, - "Data": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Dữ liệu" + "Current schema: %@ (read-only, ⌘K to switch)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schema hiện tại: %@ (chỉ đọc, ⌘K để chuyển)" } } } }, - "Data Grid": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưới dữ liệu" + "CURRENT_TIMESTAMP()" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CURRENT_TIMESTAMP()" } } } }, - "Data Size": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kích thước dữ liệu" + "Cursor position %lld exceeds SQL length (%lld)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cursor position %1$lld exceeds SQL length (%2$lld)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vị trí con trỏ %1$lld vượt quá độ dài SQL (%2$lld)" } } } }, - "Data Type:": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kiểu dữ liệu:" + "CURTIME()" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CURTIME()" } } } }, - "Data grid": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưới dữ liệu" + "Custom" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tùy chỉnh" } } } }, - "Database": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cơ sở dữ liệu" + "Cut" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cắt" } } } }, - "Database File": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tệp cơ sở dữ liệu" + "Dark" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tối" } } } }, - "Database Index: %lld": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chỉ mục cơ sở dữ liệu: %lld" + "Data" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu" } } } }, - "Database Name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên cơ sở dữ liệu" + "Data grid" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưới dữ liệu" } } } }, - "Database Switch Failed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển cơ sở dữ liệu thất bại" + "Data Grid" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưới dữ liệu" } } } }, - "Database Switcher": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển đổi cơ sở dữ liệu" + "Data Size" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kích thước dữ liệu" } } } }, - "Database file not found: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không tìm thấy tệp cơ sở dữ liệu: %@" + "Data Type:" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểu dữ liệu:" } } } }, - "Database type: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Loại cơ sở dữ liệu: %@" + "Database" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cơ sở dữ liệu" } } } }, - "Database/Schema:": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cơ sở dữ liệu/Schema:" + "Database File" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp cơ sở dữ liệu" } } } }, - "Database: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cơ sở dữ liệu: %@" + "Database file not found: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tìm thấy tệp cơ sở dữ liệu: %@" } } } }, - "Date format:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Định dạng ngày:" + "Database Index: %lld" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chỉ mục cơ sở dữ liệu: %lld" } } } }, - "Deactivate": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hủy kích hoạt" + "Database Name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên cơ sở dữ liệu" } } } }, - "Deactivate License?": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hủy kích hoạt giấy phép?" + "Database Switch Failed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển cơ sở dữ liệu thất bại" } } } }, - "Deactivate...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hủy kích hoạt..." + "Database Switcher" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển đổi cơ sở dữ liệu" } } } }, - "Deactivated": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã hủy kích hoạt" + "Database type: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loại cơ sở dữ liệu: %@" } } } }, - "Deactivation Failed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hủy kích hoạt thất bại" + "database_name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "database_name" } } } }, - "Decimal": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thập phân" + "Database: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cơ sở dữ liệu: %@" } } } }, - "Default": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mặc định" + "Database/Schema:" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cơ sở dữ liệu/Schema:" } } } }, - "Default Column": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cột mặc định" + "Databases" : { + "comment" : "A tab label for the list of databases.", + "isCommentAutoGenerated" : true + }, + "Date format:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Định dạng ngày:" } } } }, - "Default Operator": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Toán tử mặc định" + "Deactivate" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy kích hoạt" } } } }, - "Default Value": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giá trị mặc định" + "Deactivate License?" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy kích hoạt giấy phép?" } } } }, - "Default connection policy": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chính sách kết nối mặc định" + "Deactivate..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy kích hoạt..." } } } }, - "Default page size:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kích thước trang mặc định:" + "Deactivated" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã hủy kích hoạt" } } } }, - "Default value": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giá trị mặc định" + "Deactivation Failed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy kích hoạt thất bại" } } } }, - "Default:": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mặc định:" + "Decimal" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thập phân" } } } }, - "Delete": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa" + "Default" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mặc định" } } } }, - "Delete \"%@\"": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa \"%@\"" + "DEFAULT" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "MẶC ĐỊNH" } } } }, - "Delete (⌫)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa (⌫)" + "Default Column" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cột mặc định" } } } }, - "Delete Check Constraint": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa ràng buộc kiểm tra" + "Default connection policy" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chính sách kết nối mặc định" } } } }, - "Delete Column": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa cột" + "Default Operator" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toán tử mặc định" } } } }, - "Delete Connection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa kết nối" + "Default page size:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kích thước trang mặc định:" } } } }, - "Delete Foreign Key": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa khóa ngoại" + "Default value" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giá trị mặc định" } } } }, - "Delete Group": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa nhóm" + "Default Value" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giá trị mặc định" } } } }, - "Delete Index": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa chỉ mục" + "Default:" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mặc định:" } } } }, - "Delete Preset": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa mẫu đặt trước" + "Delete" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa" } } } }, - "Delimiter": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Dấu phân cách" + "Delete \"%@\"" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa \"%@\"" } } } }, - "Details": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chi tiết" + "Delete \"%@\" and its %lld connection(s)?" : { + "comment" : "A confirmation message for deleting a group that contains one or more connections. The placeholder inside the parentheses will be replaced with the name of the group.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete \"%1$@\" and its %2$lld connection(s)?" } } } }, - "Disable foreign key checks": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tắt kiểm tra khóa ngoại" + "Delete \"%@\"?" : { + "comment" : "A confirmation message for deleting a single medication group.", + "isCommentAutoGenerated" : true + }, + "Delete (⌫)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa (⌫)" } } } }, - "Disabled": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã tắt" + "Delete %lld Connection(s)" : { + "comment" : "A button title that deletes the affected connections. The argument is the count of affected connections.", + "isCommentAutoGenerated" : true + }, + "Delete %lld Connections" : { + + }, + "Delete %lld connections?" : { + "comment" : "A label for a menu item that deletes multiple connections.", + "isCommentAutoGenerated" : true + }, + "Delete %lld group(s) and %lld connection(s) total?" : { + "comment" : "A confirmation message for deleting multiple groups and connections. The first argument is the count of groups. The second argument is the count of connections.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete %1$lld group(s) and %2$lld connection(s) total?" } } } }, - "Discard": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hủy bỏ" + "Delete %lld Groups" : { + "comment" : "A menu item title that prompts the user to delete one or more groups. The number in the title corresponds to the number of groups to be deleted.", + "isCommentAutoGenerated" : true + }, + "Delete %lld groups and %lld connection(s) inside?" : { + "comment" : "A confirmation prompt for deleting multiple groups, including a count of the number of connections that will also be deleted.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete %1$lld groups and %2$lld connection(s) inside?" } } } }, - "Discard Changes?": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hủy bỏ thay đổi?" + "Delete %lld groups?" : { + "comment" : "A confirmation prompt for deleting multiple groups.", + "isCommentAutoGenerated" : true + }, + "Delete %lld Items" : { + "comment" : "A title for a menu item that deletes multiple items (either connections or groups). The number in the title corresponds to the total number of items to be deleted.", + "isCommentAutoGenerated" : true + }, + "Delete Check Constraint" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa ràng buộc kiểm tra" } } } }, - "Discard Unsaved Changes?": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hủy bỏ thay đổi chưa lưu?" + "Delete Column" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa cột" } } } }, - "Disconnect": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Ngắt kết nối" + "Delete Connection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa kết nối" } } } }, - "Disconnected": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã ngắt kết nối" + "Delete Connections" : { + "comment" : "A button that deletes a single connection.", + "isCommentAutoGenerated" : true + }, + "Delete Foreign Key" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa khóa ngoại" } } } }, - "Display": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiển thị" + "Delete Group" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa nhóm" } } } }, - "Documentation": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tài liệu" + "Delete Groups" : { + "comment" : "The title of a button that deletes multiple groups.", + "isCommentAutoGenerated" : true + }, + "Delete Index" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa chỉ mục" } } } }, - "Don't Allow": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không cho phép" + "Delete Preset" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa mẫu đặt trước" } } } }, - "Don't show this again": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không hiện lại" + "Delimiter" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dấu phân cách" } } } }, - "Drop": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa" + "Details" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chi tiết" } } } }, - "Drop %lld tables": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa %lld bảng" + "Disable foreign key checks" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tắt kiểm tra khóa ngoại" } } } }, - "Drop View": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa view" + "Disabled" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã tắt" } } } }, - "Drop table '%@'": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa bảng '%@'" + "Discard" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy bỏ" } } } }, - "Duplicate": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhân bản" + "Discard Changes?" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy bỏ thay đổi?" } } } }, - "Duplicate Existing Table": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhân bản bảng hiện có" + "Discard Unsaved Changes?" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy bỏ thay đổi chưa lưu?" } } } }, - "Duplicate Filter": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhân bản bộ lọc" + "Disconnect" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngắt kết nối" } } } }, - "Duplicate Row": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhân bản dòng" + "Disconnected" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã ngắt kết nối" } } } }, - "Duplicate Table Structure": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhân bản cấu trúc bảng" + "Display" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiển thị" } } } }, - "Duplicate filter": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhân đôi bộ lọc" + "Documentation" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tài liệu" } } } }, - "EU Long (31/12/2024 23:59:59)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Châu Âu dài (31/12/2024 23:59:59)" + "Don't Allow" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không cho phép" } } } }, - "EU Short (31/12/2024)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Châu Âu ngắn (31/12/2024)" + "Don't show this again" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không hiện lại" } } } }, - "Each SQLite file is a separate database.\nTo open a different database, create a new connection.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mỗi tệp SQLite là một cơ sở dữ liệu riêng.\nĐể mở cơ sở dữ liệu khác, hãy tạo kết nối mới." + "Drop" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa" } } } }, - "Edit": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sửa" + "Drop %lld tables" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa %lld bảng" } } } }, - "Edit Connection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sửa kết nối" + "Drop table '%@'" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa bảng '%@'" } } } }, - "Edit Details (Double-click)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sửa chi tiết (Nhấp đúp)" + "Drop View" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa view" } } } }, - "Edit Provider": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sửa nhà cung cấp" + "Duplicate" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhân bản" } } } }, - "Edit Row": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sửa dòng" + "Duplicate Existing Table" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhân bản bảng hiện có" } } } }, - "Edit View Definition": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sửa định nghĩa view" + "Duplicate filter" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhân đôi bộ lọc" } } } }, - "Editing": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang sửa" + "Duplicate Filter" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhân bản bộ lọc" } } } }, - "Editor": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Trình soạn thảo" + "Duplicate Row" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhân bản dòng" } } } }, - "Email:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Email:" + "Duplicate Table Structure" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhân bản cấu trúc bảng" } } } }, - "Empty": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Trống" + "Each SQLite file is a separate database.\nTo open a different database, create a new connection." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mỗi tệp SQLite là một cơ sở dữ liệu riêng.\nĐể mở cơ sở dữ liệu khác, hãy tạo kết nối mới." } } } }, - "Empty Redis command": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lệnh Redis trống" + "Edit" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sửa" } } } }, - "Enable SSH Tunnel": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật đường hầm SSH" + "Edit Connection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sửa kết nối" } } } }, - "Enable inline suggestions": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật gợi ý trực tiếp" + "Edit Details (Double-click)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sửa chi tiết (Nhấp đúp)" } } } }, - "Enabled": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã bật" + "Edit Group" : { + "comment" : "The title of the edit form sheet for a connection group.", + "isCommentAutoGenerated" : true + }, + "Edit Group..." : { + "comment" : "Title of a menu item that allows editing an existing connection group.", + "isCommentAutoGenerated" : true + }, + "Edit Provider" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sửa nhà cung cấp" } } } }, - "Encoding:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mã hóa:" + "Edit Row" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sửa dòng" } } } }, - "Endpoint": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Endpoint" + "Edit View Definition" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sửa định nghĩa view" } } } }, - "Engine": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Engine" + "Editing" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang sửa" } } } }, - "Engine (e.g., InnoDB)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Engine (vd: InnoDB)" + "Editor" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trình soạn thảo" } } } }, - "Enter a name for this filter preset": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập tên cho mẫu bộ lọc này" + "Email:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Email:" } } } }, - "Enter a new name for the group.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập tên mới cho nhóm." + "Empty" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trống" } } } }, - "Enter database name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập tên cơ sở dữ liệu" + "Empty Redis command" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lệnh Redis trống" } } } }, - "Error": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lỗi" + "Enable inline suggestions" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật gợi ý trực tiếp" } } } }, - "Error Applying Changes": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lỗi áp dụng thay đổi" + "Enable SSH Tunnel" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật đường hầm SSH" } } } }, - "Error:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lỗi:" + "Enabled" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã bật" } } } }, - "Error: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lỗi: %@" + "Encoding:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mã hóa:" } } } }, - "Error: Selected path is not a regular file": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lỗi: Đường dẫn đã chọn không phải tệp thông thường" + "Endpoint" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Endpoint" } } } }, - "Every table needs at least one column. Click + to get started": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mỗi bảng cần ít nhất một cột. Nhấn + để bắt đầu" + "ends with" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "kết thúc bằng" } } } }, - "Execute": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thực thi" + "Engine" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Engine" } } } }, - "Execute All": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thực thi tất cả" + "Engine (e.g., InnoDB)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Engine (vd: InnoDB)" } } } }, - "Execute all statements in a single transaction. If any statement fails, all changes are rolled back.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thực thi tất cả câu lệnh trong một giao dịch. Nếu bất kỳ câu lệnh nào thất bại, tất cả thay đổi sẽ được hoàn tác." + "Enter a name for this filter preset" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập tên cho mẫu bộ lọc này" } } } }, - "Executed %lld statements": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã thực thi %lld câu lệnh" + "Enter a new name for the group." : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập tên mới cho nhóm." } } } }, - "Executing": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang thực thi" + "Enter database name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập tên cơ sở dữ liệu" } } } }, - "Executing...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang thực thi..." + "equals" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bằng" } } } }, - "Expired": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hết hạn" + "Error" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi" } } } }, - "Explain": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giải thích" + "Error Applying Changes" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi áp dụng thay đổi" } } } }, - "Explain Query": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giải thích truy vấn" + "Error:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi:" } } } }, - "Explain this SQL query:\n\n```sql\n%@\n```": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giải thích chi tiết câu truy vấn SQL sau:\n\n```sql\n%@\n```" + "Error: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi: %@" } } } }, - "Explain with AI": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giải thích với AI" + "Error: Selected path is not a regular file" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi: Đường dẫn đã chọn không phải tệp thông thường" } } } }, - "Export": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuất" + "EU Long (31/12/2024 23:59:59)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Châu Âu dài (31/12/2024 23:59:59)" } } } }, - "Export Data": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuất dữ liệu" + "EU Short (31/12/2024)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Châu Âu ngắn (31/12/2024)" } } } }, - "Export Data (⌘⇧E)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuất dữ liệu (⌘⇧E)" + "Every table needs at least one column. Click + to get started" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mỗi bảng cần ít nhất một cột. Nhấn + để bắt đầu" } } } }, - "Export Error": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lỗi xuất dữ liệu" + "Execute" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thực thi" } } } }, - "Export completed successfully": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuất dữ liệu thành công" + "Execute All" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thực thi tất cả" } } } }, - "Export data": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuất dữ liệu" + "Execute all statements in a single transaction. If any statement fails, all changes are rolled back." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thực thi tất cả câu lệnh trong một giao dịch. Nếu bất kỳ câu lệnh nào thất bại, tất cả thay đổi sẽ được hoàn tác." } } } }, - "Export failed: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuất thất bại: %@" + "Executed %lld statements" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã thực thi %lld câu lệnh" } } } }, - "Export multiple tables": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuất nhiều bảng" + "Executing" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang thực thi" } } } }, - "Export table": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuất bảng" + "Executing..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang thực thi..." } } } }, - "Export...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuất..." + "Expired" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hết hạn" } } } }, - "Exports data as mongosh-compatible scripts. Drop, Indexes, and Data options are configured per collection in the collection list.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuất dữ liệu dưới dạng script tương thích mongosh. Tùy chọn Drop, Indexes và Data được cấu hình cho từng collection trong danh sách collection." + "Explain" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giải thích" } } } }, - "Expression (e.g., age >= 0)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Biểu thức (vd: age >= 0)" + "Explain Query" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giải thích truy vấn" } } } }, - "FALSE": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "FALSE" + "Explain this SQL query:\n\n```sql\n%@\n```" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giải thích chi tiết câu truy vấn SQL sau:\n\n```sql\n%@\n```" } } } }, - "FIELDS": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "TRƯỜNG" + "Explain with AI" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giải thích với AI" } } } }, - "FIELDS (%lld)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "CÁC TRƯỜNG (%lld)" + "export" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xuất" } } } }, - "Failed at line %lld": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thất bại tại dòng %lld" + "Export" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất" } } } }, - "Failed to Save Changes": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu thay đổi thất bại" + "Export completed successfully" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất dữ liệu thành công" } } } }, - "Failed to compress data": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không thể nén dữ liệu" + "Export data" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất dữ liệu" } } } }, - "Failed to decompress .gz file": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giải nén tệp .gz thất bại" + "Export Data" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất dữ liệu" } } } }, - "Failed to decompress file: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giải nén tệp thất bại: %@" + "Export Data (⌘⇧E)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất dữ liệu (⌘⇧E)" } } } }, - "Failed to delete template: %@": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa mẫu thất bại: %@" + "Export Error" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi xuất dữ liệu" } } } }, - "Failed to encode content as UTF-8": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không thể mã hóa nội dung thành UTF-8" + "Export failed: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất thất bại: %@" } } } }, - "Failed to fetch table structure: %@": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lấy cấu trúc bảng thất bại: %@" + "Export multiple tables" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất nhiều bảng" } } } }, - "Failed to import DDL: %@": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập DDL thất bại: %@" + "Export table" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất bảng" } } } }, - "Failed to load databases": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải danh sách cơ sở dữ liệu thất bại" + "Export..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất..." } } } }, - "Failed to load databases: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải danh sách cơ sở dữ liệu thất bại: %@" + "Exports data as mongosh-compatible scripts. Drop, Indexes, and Data options are configured per collection in the collection list." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất dữ liệu dưới dạng script tương thích mongosh. Tùy chọn Drop, Indexes và Data được cấu hình cho từng collection trong danh sách collection." } } } }, - "Failed to load preview using encoding: %@. Try selecting a different text encoding from the encoding picker and reload the preview.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải bản xem trước thất bại với mã hóa: %@. Hãy thử chọn mã hóa văn bản khác và tải lại bản xem trước." + "Expression (e.g., age >= 0)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Biểu thức (vd: age >= 0)" } } } }, - "Failed to load preview: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải bản xem trước thất bại: %@" + "Failed at line %lld" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thất bại tại dòng %lld" } } } }, - "Failed to load schemas": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không thể tải danh sách schema" + "Failed to compress data" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể nén dữ liệu" } } } }, - "Failed to load tables: %@": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải danh sách bảng thất bại: %@" + "Failed to decompress .gz file" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giải nén tệp .gz thất bại" } } } }, - "Failed to load template: %@": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải mẫu thất bại: %@" + "Failed to decompress file: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giải nén tệp thất bại: %@" } } } }, - "Failed to parse any columns from table '%@'. Check console for debug info.": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không thể phân tích cột từ bảng '%@'. Kiểm tra console để xem thông tin gỡ lỗi." + "Failed to delete template: %@" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa mẫu thất bại: %@" } } } }, - "Failed to parse statement at line %lld: %@": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "Failed to parse statement at line %1$lld: %2$@" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phân tích câu lệnh thất bại tại dòng %1$lld: %2$@" + "Failed to encode content as UTF-8" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể mã hóa nội dung thành UTF-8" } } } }, - "Failed to read file: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đọc tệp thất bại: %@" + "Failed to fetch table structure: %@" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lấy cấu trúc bảng thất bại: %@" } } } }, - "Failed to save template: %@": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu mẫu thất bại: %@" + "Failed to import DDL: %@" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập DDL thất bại: %@" } } } }, - "Failed to write file: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không thể ghi tệp: %@" + "Failed to load databases" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tải danh sách cơ sở dữ liệu thất bại" } } } }, - "Feature Routing": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Định tuyến tính năng" + "Failed to load databases: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tải danh sách cơ sở dữ liệu thất bại: %@" } } } }, - "File": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tệp" + "Failed to load preview using encoding: %@. Try selecting a different text encoding from the encoding picker and reload the preview." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tải bản xem trước thất bại với mã hóa: %@. Hãy thử chọn mã hóa văn bản khác và tải lại bản xem trước." } } } }, - "File Path": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đường dẫn tệp" + "Failed to load preview: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tải bản xem trước thất bại: %@" } } } }, - "File name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên tệp" + "Failed to load schemas" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể tải danh sách schema" } } } }, - "File not found": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không tìm thấy tệp" + "Failed to load tables: %@" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tải danh sách bảng thất bại: %@" } } } }, - "Filter": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bộ lọc" + "Failed to load template: %@" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tải mẫu thất bại: %@" } } } }, - "Filter Settings": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cài đặt bộ lọc" + "Failed to parse any columns from table '%@'. Check console for debug info." : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể phân tích cột từ bảng '%@'. Kiểm tra console để xem thông tin gỡ lỗi." } } } }, - "Filter column: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cột lọc: %@" + "Failed to parse statement at line %lld: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to parse statement at line %1$lld: %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phân tích câu lệnh thất bại tại dòng %1$lld: %2$@" } } } }, - "Filter logic mode": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chế độ logic bộ lọc" + "Failed to read file: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đọc tệp thất bại: %@" } } } }, - "Filter operator: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Toán tử lọc: %@" + "Failed to Save Changes" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu thay đổi thất bại" } } } }, - "Filter presets": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cài đặt sẵn bộ lọc" + "Failed to save template: %@" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu mẫu thất bại: %@" } } } }, - "Filter settings": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cài đặt bộ lọc" + "Failed to write file: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể ghi tệp: %@" } } } }, - "Filter with column": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lọc theo cột" + "false" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "false" } } } }, - "Filters": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bộ lọc" + "FALSE" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "FALSE" } } } }, - "Fix Error": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sửa lỗi" + "Feature Routing" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Định tuyến tính năng" } } } }, - "Font": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phông chữ" + "FIELDS" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TRƯỜNG" } } } }, - "Font:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phông chữ:" + "FIELDS (%lld)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CÁC TRƯỜNG (%lld)" } } } }, - "Forever": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mãi mãi" + "File" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp" } } } }, - "Format Query (⌥⌘F)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Định dạng truy vấn (⌥⌘F)" + "File name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên tệp" } } } }, - "Formatter error: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lỗi định dạng: %@" + "File not found" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tìm thấy tệp" } } } }, - "Formatting not supported for %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không hỗ trợ định dạng cho %@" + "File Path" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đường dẫn tệp" } } } }, - "General": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tổng quát" + "Filter" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bộ lọc" } } } }, - "Generated WHERE Clause": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mệnh đề WHERE đã tạo" + "Filter column: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cột lọc: %@" } } } }, - "Generation failed.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo phản hồi thất bại." + "Filter logic mode" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chế độ logic bộ lọc" } } } }, - "Get Started": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bắt đầu" + "Filter operator: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toán tử lọc: %@" } } } }, - "Get help writing queries, explaining schemas, or fixing errors.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhận trợ giúp viết truy vấn, giải thích schema hoặc sửa lỗi." + "Filter presets" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt sẵn bộ lọc" } } } }, - "Go": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đi" + "Filter settings" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt bộ lọc" } } } }, - "Go to Settings…": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đi đến Cài đặt…" + "Filter Settings" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt bộ lọc" } } } }, - "Graphite": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Than chì" + "Filter with column" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lọc theo cột" } } } }, - "Gray": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xám" + "Filters" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bộ lọc" } } } }, - "Green": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xanh lá" + "Fix Error" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sửa lỗi" } } } }, - "Group": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhóm" + "Font" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phông chữ" } } } }, - "Group name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên nhóm" + "Font:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phông chữ:" } } } }, - "Help improve TablePro by sharing anonymous usage statistics (no personal data or queries).": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giúp cải thiện TablePro bằng cách chia sẻ thống kê sử dụng ẩn danh (không có dữ liệu cá nhân hay truy vấn nào)." + "Forever" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mãi mãi" } } } }, - "Higher values create fewer INSERT statements, resulting in smaller files and faster imports": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giá trị cao hơn tạo ít câu lệnh INSERT hơn, giúp tệp nhỏ hơn và nhập nhanh hơn" + "Format Query (⌥⌘F)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Định dạng truy vấn (⌥⌘F)" } } } }, - "Highlight current line": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đánh dấu dòng hiện tại" + "Formatter error: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi định dạng: %@" } } } }, - "History": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lịch sử" + "Formatting not supported for %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không hỗ trợ định dạng cho %@" } } } }, - "Host": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Máy chủ" + "General" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tổng quát" } } } }, - "INDEX": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "CHỈ MỤC" + "Generated WHERE Clause" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mệnh đề WHERE đã tạo" } } } }, - "ISO 8601 (2024-12-31 23:59:59)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "ISO 8601 (2024-12-31 23:59:59)" + "Generation failed." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo phản hồi thất bại." } } } }, - "ISO Date (2024-12-31)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "ISO ngày (2024-12-31)" + "Get help writing queries, explaining schemas, or fixing errors." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhận trợ giúp viết truy vấn, giải thích schema hoặc sửa lỗi." } } } }, - "Ignore foreign key checks": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bỏ qua kiểm tra khóa ngoại" + "Get Started" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bắt đầu" } } } }, - "Import": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập" + "Go" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đi" } } } }, - "Import Data": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập dữ liệu" + "Go to Settings…" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đi đến Cài đặt…" } } } }, - "Import Data (⌘⇧I)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập dữ liệu (⌘⇧I)" + "Graphite" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Than chì" } } } }, - "Import Failed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập thất bại" + "Gray" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xám" } } } }, - "Import Not Supported": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không hỗ trợ nhập" + "greater or equal" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "lớn hơn hoặc bằng" } } } }, - "Import SQL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập SQL" + "greater than" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "lớn hơn" } } } }, - "Import Successful": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập thành công" + "Green" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xanh lá" } } } }, - "Import cancelled by user": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Người dùng đã hủy nhập" + "Group" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhóm" } } } }, - "Import data": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập dữ liệu" + "Group name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên nhóm" } } } }, - "Import failed at line %lld: %@": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "Import failed at line %1$lld: %2$@" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập thất bại tại dòng %1$lld: %2$@" + "Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giúp cải thiện TablePro bằng cách chia sẻ thống kê sử dụng ẩn danh (không có dữ liệu cá nhân hay truy vấn nào)." } } } }, - "Import from DDL": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập từ DDL" + "Higher values create fewer INSERT statements, resulting in smaller files and faster imports" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giá trị cao hơn tạo ít câu lệnh INSERT hơn, giúp tệp nhỏ hơn và nhập nhanh hơn" } } } }, - "Import from URL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập từ URL" + "Highlight current line" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đánh dấu dòng hiện tại" } } } }, - "Import...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập..." + "History" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lịch sử" } } } }, - "Include NULL values": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bao gồm giá trị NULL" + "history entries" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mục lịch sử" } } } }, - "Include column headers": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bao gồm tiêu đề cột" + "history entry" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mục lịch sử" } } } }, - "Include current query": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bao gồm truy vấn hiện tại" + "Host" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Máy chủ" } } } }, - "Include database schema": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bao gồm schema cơ sở dữ liệu" + "Ignore foreign key checks" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bỏ qua kiểm tra khóa ngoại" } } } }, - "Include query results": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bao gồm kết quả truy vấn" + "Import" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập" } } } }, - "Index Size": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kích thước chỉ mục" + "Import cancelled by user" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Người dùng đã hủy nhập" } } } }, - "Index name": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên chỉ mục" + "Import data" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập dữ liệu" } } } }, - "Indexes": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chỉ mục" + "Import Data" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập dữ liệu" } } } }, - "Inline Suggestions": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Gợi ý nội tuyến" + "Import Data (⌘⇧I)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập dữ liệu (⌘⇧I)" } } } }, - "Insert": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chèn" + "Import Failed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập thất bại" } } } }, - "Inspector": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thanh kiểm tra" + "Import failed at line %lld: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Import failed at line %1$lld: %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập thất bại tại dòng %1$lld: %2$@" } } } }, - "Invalid JSON": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "JSON không hợp lệ" + "Import from DDL" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập từ DDL" } } } }, - "Invalid JSON: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "JSON không hợp lệ: %@" + "Import from URL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập từ URL" } } } }, - "Invalid MongoDB syntax: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cú pháp MongoDB không hợp lệ: %@" + "Import Not Supported" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không hỗ trợ nhập" } } } }, - "Invalid argument: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đối số không hợp lệ: %@" + "Import SQL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập SQL" } } } }, - "Invalid connection URL format": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Định dạng URL kết nối không hợp lệ" + "Import Successful" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập thành công" } } } }, - "Invalid data format: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Định dạng dữ liệu không hợp lệ: %@" + "Import..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập..." } } } }, - "Invalid endpoint: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Endpoint không hợp lệ: %@" + "in list" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trong danh sách" } } } }, - "Invalid file encoding. Try a different encoding option.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mã hóa tệp không hợp lệ. Hãy thử tùy chọn mã hóa khác." + "Include column headers" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bao gồm tiêu đề cột" } } } }, - "Invalid username or password": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên đăng nhập hoặc mật khẩu không hợp lệ" + "Include current query" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bao gồm truy vấn hiện tại" } } } }, - "Items": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mục" + "Include database schema" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bao gồm schema cơ sở dữ liệu" } } } }, - "Keep entries for:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giữ mục trong:" + "Include NULL values" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bao gồm giá trị NULL" } } } }, - "Keep leading zeros in ZIP codes, phone numbers, and IDs by outputting all values as strings": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giữ số 0 đầu trong mã bưu chính, số điện thoại và ID bằng cách xuất tất cả giá trị dưới dạng chuỗi" + "Include query results" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bao gồm kết quả truy vấn" } } } }, - "Key File": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tệp khóa" + "INDEX" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CHỈ MỤC" } } } }, - "Keyboard": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bàn phím" + "Index name" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên chỉ mục" } } } }, - "Language:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Ngôn ngữ:" + "Index Size" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kích thước chỉ mục" } } } }, - "Last query execution time": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thời gian thực thi truy vấn gần nhất" + "Indexes" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chỉ mục" } } } }, - "Last query took %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truy vấn trước mất %@" + "Inline Suggestions" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gợi ý nội tuyến" } } } }, - "Latency: %lldms": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Độ trễ: %lldms" + "Insert" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chèn" } } } }, - "Length": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Độ dài" + "Inspector" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thanh kiểm tra" } } } }, - "Length:": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Độ dài:" + "Invalid argument: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đối số không hợp lệ: %@" } } } }, - "License": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giấy phép" + "Invalid connection URL format" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Định dạng URL kết nối không hợp lệ" } } } }, - "License Key:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mã giấy phép:" + "Invalid data format: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Định dạng dữ liệu không hợp lệ: %@" } } } }, - "Light": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sáng" + "Invalid endpoint: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Endpoint không hợp lệ: %@" } } } }, - "Limit": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giới hạn" + "Invalid file encoding. Try a different encoding option." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mã hóa tệp không hợp lệ. Hãy thử tùy chọn mã hóa khác." } } } }, - "Line break": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xuống dòng" + "Invalid JSON" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON không hợp lệ" } } } }, - "Load": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải" + "Invalid JSON: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON không hợp lệ: %@" } } } }, - "Load Table Template": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải mẫu bảng" + "Invalid MongoDB syntax: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cú pháp MongoDB không hợp lệ: %@" } } } }, - "Load Template": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải mẫu" + "Invalid username or password" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên đăng nhập hoặc mật khẩu không hợp lệ" } } } }, - "Load in Editor": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tải vào trình soạn thảo" + "is empty" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trống" } } } }, - "Loading databases...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang tải cơ sở dữ liệu..." + "is not empty" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không trống" } } } }, - "Loading schemas...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang tải schema..." + "is not NULL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không phải NULL" } } } }, - "Loading tables...": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang tải danh sách bảng..." + "is NULL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "là NULL" } } } }, - "METADATA": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "SIÊU DỮ LIỆU" + "ISO 8601 (2024-12-31 23:59:59)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ISO 8601 (2024-12-31 23:59:59)" } } } }, - "MQL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "MQL" + "ISO Date (2024-12-31)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ISO ngày (2024-12-31)" } } } }, - "MQL Preview": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước MQL" + "Items" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mục" } } } }, - "Maintenance": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bảo trì" + "Keep entries for:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giữ mục trong:" } } } }, - "Majority": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Majority" + "Keep leading zeros in ZIP codes, phone numbers, and IDs by outputting all values as strings" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giữ số 0 đầu trong mã bưu chính, số điện thoại và ID bằng cách xuất tất cả giá trị dưới dạng chuỗi" } } } }, - "Manage Connections...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Quản lý kết nối..." + "Key File" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp khóa" } } } }, - "Manage Tags": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Quản lý thẻ" + "Keyboard" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bàn phím" } } } }, - "Manual": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thủ công" + "Language:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngôn ngữ:" } } } }, - "Match ALL filters (AND) or ANY filter (OR)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Khớp TẤT CẢ bộ lọc (AND) hoặc BẤT KỲ bộ lọc (OR)" + "Last query execution time" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian thực thi truy vấn gần nhất" } } } }, - "Max %lld characters": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tối đa %lld ký tự" + "Last query took %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn trước mất %@" } } } }, - "Max schema tables: %lld": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Số bảng tối đa: %lld" + "Latency: %lldms" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Độ trễ: %lldms" } } } }, - "Maximum days cannot be negative": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Số ngày tối đa không được là số âm" + "Length" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Độ dài" } } } }, - "Maximum entries cannot be negative": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Số mục tối đa không được là số âm" + "Length:" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Độ dài:" } } } }, - "Maximum entries:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Số mục tối đa:" + "less or equal" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "nhỏ hơn hoặc bằng" } } } }, - "Maximum time to wait for a query to complete. Set to 0 for no limit. Applied to new connections.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thời gian chờ tối đa để truy vấn hoàn thành. Đặt 0 để không giới hạn. Áp dụng cho kết nối mới." + "less than" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "nhỏ hơn" } } } }, - "Method": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phương thức" + "License" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giấy phép" } } } }, - "Missing argument: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thiếu đối số: %@" + "License Key:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mã giấy phép:" } } } }, - "Model": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Model" + "Light" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sáng" } } } }, - "Model not found: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không tìm thấy model: %@" + "Limit" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giới hạn" } } } }, - "MongoDB": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "MongoDB" + "Line break" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuống dòng" } } } }, - "Move Down": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Di chuyển xuống" + "Load" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tải" } } } }, - "Move Down (⌘↓)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Di chuyển xuống (⌘↓)" + "Load in Editor" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tải vào trình soạn thảo" } } } }, - "Move Up": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Di chuyển lên" + "Load Table Template" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tải mẫu bảng" } } } }, - "Move Up (⌘↑)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Di chuyển lên (⌘↑)" + "Load Template" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tải mẫu" } } } }, - "Multiple values": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhiều giá trị" + "Loading databases..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang tải cơ sở dữ liệu..." } } } }, - "NOT NULL": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "NOT NULL" + "Loading schemas..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang tải schema..." } } } }, - "NOW()": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "NOW()" + "Loading tables..." : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang tải danh sách bảng..." } } } }, - "NULL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "NULL" + "localhost" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "localhost" } } } }, - "NULL display cannot be empty": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiển thị NULL không được để trống" + "Maintenance" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảo trì" } } } }, - "NULL display contains invalid characters (newlines/tabs)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiển thị NULL chứa ký tự không hợp lệ (xuống dòng/tab)" + "Majority" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Majority" } } } }, - "NULL display must be %lld characters or less": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiển thị NULL phải có %lld ký tự trở xuống" + "Manage Connections..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quản lý kết nối..." } } } }, - "NULL display:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiển thị NULL:" + "Manage Groups" : { + "comment" : "A menu title that allows users to manage their connection groups.", + "isCommentAutoGenerated" : true + }, + "Manage Tags" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quản lý thẻ" } } } }, - "Name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên" + "Manual" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thủ công" } } } }, - "Navigate to referenced row": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đi đến dòng được tham chiếu" + "Match ALL filters (AND) or ANY filter (OR)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khớp TẤT CẢ bộ lọc (AND) hoặc BẤT KỲ bộ lọc (OR)" } } } }, - "Nearest": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nearest" + "matches regex" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "khớp regex" } } } }, - "Network error: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lỗi mạng: %@" + "Max %lld characters" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tối đa %lld ký tự" } } } }, - "Never": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không bao giờ" + "Max schema tables: %lld" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số bảng tối đa: %lld" } } } }, - "New Chat": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cuộc trò chuyện mới" + "Maximum days cannot be negative" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số ngày tối đa không được là số âm" } } } }, - "New Connection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối mới" + "Maximum entries cannot be negative" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số mục tối đa không được là số âm" } } } }, - "New Connection (⌘N)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối mới (⌘N)" + "Maximum entries:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số mục tối đa:" } } } }, - "New Connection...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối mới..." + "Maximum time to wait for a query to complete. Set to 0 for no limit. Applied to new connections." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian chờ tối đa để truy vấn hoàn thành. Đặt 0 để không giới hạn. Áp dụng cho kết nối mới." } } } }, - "New Conversation": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hội thoại mới" + "METADATA" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SIÊU DỮ LIỆU" } } } }, - "New Group": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhóm mới" + "Method" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phương thức" } } } }, - "New Query Tab": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tab truy vấn mới" + "Missing argument: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thiếu đối số: %@" } } } }, - "New Query Tab (⌘T)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tab truy vấn mới (⌘T)" + "Model" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model" } } } }, - "New Tab": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tab mới" + "Model not found: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tìm thấy model: %@" } } } }, - "New View...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "View mới..." + "MongoDB" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "MongoDB" } } } }, - "New query tab": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tab truy vấn mới" + "Move Down" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Di chuyển xuống" } } } }, - "Next Page (⌘])": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Trang sau (⌘])" + "Move Down (⌘↓)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Di chuyển xuống (⌘↓)" } } } }, - "Next Tab": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tab tiếp" + "Move to Group" : { + "comment" : "Label for the submenu item that triggers the display of the move-to-group submenu.", + "isCommentAutoGenerated" : true + }, + "Move Up" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Di chuyển lên" } } } }, - "Next Tab (Alt)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tab tiếp theo (Alt)" + "Move Up (⌘↑)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Di chuyển lên (⌘↑)" } } } }, - "No AI provider configured. Go to Settings > AI to add one.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa cấu hình nhà cung cấp AI. Vào Cài đặt > AI để thêm." + "MQL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQL" } } } }, - "No Check Constraints": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có ràng buộc kiểm tra" + "MQL Preview" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước MQL" } } } }, - "No Columns Defined": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa có cột nào" + "Multiple values" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhiều giá trị" } } } }, - "No DDL available": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có DDL" + "Name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên" } } } }, - "No Foreign Keys Yet": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa có khóa ngoại" + "Navigate to referenced row" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đi đến dòng được tham chiếu" } } } }, - "No Indexes Defined": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa có chỉ mục" + "Nearest" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nearest" } } } }, - "No Matching Queries": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có truy vấn phù hợp" + "Network error: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi mạng: %@" } } } }, - "No Query History Yet": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa có lịch sử truy vấn" + "Never" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không bao giờ" } } } }, - "No SSL encryption": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không mã hóa SSL" + "New Chat" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuộc trò chuyện mới" } } } }, - "No Selection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa chọn" + "New Connection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối mới" } } } }, - "No Tables": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có bảng" + "New Connection (⌘N)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối mới (⌘N)" } } } }, - "No active connection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có kết nối đang hoạt động" + "New Connection..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối mới..." } } } }, - "No available local port for SSH tunnel": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có cổng nội bộ khả dụng cho đường hầm SSH" + "New Conversation" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hội thoại mới" } } } }, - "No changes to preview": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có thay đổi để xem trước" + "New Group" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhóm mới" } } } }, - "No connections yet": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa có kết nối nào" + "New Group..." : { + "comment" : "Title of a menu item in the context menu that allows creating a new group.", + "isCommentAutoGenerated" : true + }, + "New query tab" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab truy vấn mới" } } } }, - "No database connection": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa kết nối cơ sở dữ liệu" + "New Query Tab" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab truy vấn mới" } } } }, - "No databases found": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không tìm thấy cơ sở dữ liệu" + "New Query Tab (⌘T)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab truy vấn mới (⌘T)" } } } }, - "No databases match \"%@\"": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có cơ sở dữ liệu nào khớp \"%@\"" + "New Subgroup..." : { + "comment" : "Title of a menu item in the context menu that allows creating a new subgroup within an existing group.", + "isCommentAutoGenerated" : true + }, + "New Tab" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab mới" } } } }, - "No limit": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không giới hạn" + "New View..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "View mới..." } } } }, - "No matching connections": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có kết nối nào khớp" + "Next Page (⌘])" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trang sau (⌘])" } } } }, - "No matching databases": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có cơ sở dữ liệu nào khớp" + "Next Tab" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab tiếp" } } } }, - "No matching fields": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có trường khớp" + "Next Tab (Alt)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab tiếp theo (Alt)" } } } }, - "No matching schemas": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có schema khớp" + "No active connection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có kết nối đang hoạt động" } } } }, - "No matching tables": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có bảng nào khớp" + "No AI provider configured. Go to Settings > AI to add one." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa cấu hình nhà cung cấp AI. Vào Cài đặt > AI để thêm." } } } }, - "No model selected": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa chọn model" + "No available local port for SSH tunnel" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có cổng nội bộ khả dụng cho đường hầm SSH" } } } }, - "No models loaded": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa tải mô hình nào" + "No changes to preview" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có thay đổi để xem trước" } } } }, - "No pending changes": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có thay đổi nào" + "No Check Constraints" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có ràng buộc kiểm tra" } } } }, - "No primary key selected (not recommended)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa chọn khóa chính (không khuyến nghị)" + "No Columns Defined" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa có cột nào" } } } }, - "No providers configured": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa cấu hình nhà cung cấp" + "No connections yet" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa có kết nối nào" } } } }, - "No query executed yet": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa thực hiện truy vấn nào" + "No database connection" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa kết nối cơ sở dữ liệu" } } } }, - "No rows": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có dòng" + "No databases found" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tìm thấy cơ sở dữ liệu" } } } }, - "No saved connection named \"%@\".": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có kết nối đã lưu tên \"%@\"." + "No databases match \"%@\"" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có cơ sở dữ liệu nào khớp \"%@\"" } } } }, - "No saved templates": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có mẫu đã lưu" + "No DDL available" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có DDL" } } } }, - "No schemas found": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không tìm thấy schema" + "No Foreign Keys Yet" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa có khóa ngoại" } } } }, - "No schemas match \"%@\"": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có schema khớp \"%@\"" + "No Indexes Defined" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa có chỉ mục" } } } }, - "No selection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa chọn" + "No limit" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không giới hạn" } } } }, - "No tables selected for export": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có bảng nào được chọn để xuất" + "No matching connections" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có kết nối nào khớp" } } } }, - "No tabs open": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có tab nào mở" + "No matching databases" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có cơ sở dữ liệu nào khớp" } } } }, - "No valid rows found in clipboard data.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không tìm thấy dòng hợp lệ trong dữ liệu bộ nhớ tạm." + "No matching fields" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có trường khớp" } } } }, - "No values found": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không tìm thấy giá trị" + "No Matching Queries" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có truy vấn phù hợp" } } } }, - "None": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không" + "No matching schemas" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có schema khớp" } } } }, - "Normal": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bình thường" + "No matching tables" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có bảng nào khớp" } } } }, - "Not connected": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa kết nối" + "No model selected" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa chọn model" } } } }, - "Not connected to database": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa kết nối cơ sở dữ liệu" + "No models loaded" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa tải mô hình nào" } } } }, - "Nullable": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cho phép NULL" + "No pending changes" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có thay đổi nào" } } } }, - "Number of documents per insertMany statement. Higher values create fewer statements.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Số lượng document cho mỗi câu lệnh insertMany. Giá trị cao hơn tạo ít câu lệnh hơn." + "No primary key selected (not recommended)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa chọn khóa chính (không khuyến nghị)" } } } }, - "OK": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "OK" + "No providers configured" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa cấu hình nhà cung cấp" } } } }, - "OR": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "OR" + "No query executed yet" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa thực hiện truy vấn nào" } } } }, - "Offset": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Vị trí bắt đầu" + "No Query History Yet" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa có lịch sử truy vấn" } } } }, - "On Delete": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Khi xóa" + "No rows" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có dòng" } } } }, - "On Update": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Khi cập nhật" + "No saved connection named \"%@\"." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có kết nối đã lưu tên \"%@\"." } } } }, - "Open": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở" + "No saved templates" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có mẫu đã lưu" } } } }, - "Open Connection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở kết nối" + "No schemas found" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tìm thấy schema" } } } }, - "Open Database": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở cơ sở dữ liệu" + "No schemas match \"%@\"" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có schema khớp \"%@\"" } } } }, - "Open Database (⌘K)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở cơ sở dữ liệu (⌘K)" + "No selection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa chọn" } } } }, - "Open Database...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở cơ sở dữ liệu..." + "No Selection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa chọn" } } } }, - "Open MQL Editor": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở trình soạn MQL" + "No SSL encryption" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không mã hóa SSL" } } } }, - "Open Redis CLI": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở Redis CLI" + "No Tables" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có bảng" } } } }, - "Open SQL Editor": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở trình soạn SQL" + "No tables selected for export" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có bảng nào được chọn để xuất" } } } }, - "Open Schema": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở Schema" + "No tabs open" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có tab nào mở" } } } }, - "Open containing folder": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở thư mục chứa" + "No valid rows found in clipboard data." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tìm thấy dòng hợp lệ trong dữ liệu bộ nhớ tạm." } } } }, - "Open database": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở cơ sở dữ liệu" + "No values found" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tìm thấy giá trị" } } } }, - "Optimize Query": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tối ưu truy vấn" + "None" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không" } } } }, - "Optimize this SQL query for better performance:\n\n```sql\n%@\n```": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đề xuất tối ưu hóa cho câu truy vấn SQL sau:\n\n```sql\n%@\n```" + "Normal" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bình thường" } } } }, - "Optimize with AI": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tối ưu với AI" + "Not connected" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa kết nối" } } } }, - "Optional description": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mô tả tùy chọn" + "Not connected to database" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa kết nối cơ sở dữ liệu" } } } }, - "Options": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tùy chọn" + "not contains" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không chứa" } } } }, - "Orange": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cam" + "not equals" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không bằng" } } } }, - "Page size must be between %@ and %@": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "Page size must be between %1$@ and %2$@" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kích thước trang phải nằm trong khoảng %1$@ đến %2$@" + "not in list" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không trong danh sách" } } } }, - "Pagination": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phân trang" + "NOT NULL" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOT NULL" } } } }, - "Pagination Settings": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cài đặt phân trang" + "NOW()" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOW()" } } } }, - "Panel State": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Trạng thái bảng điều khiển" + "NULL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "NULL" } } } }, - "Passphrase": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cụm mật khẩu" + "NULL display cannot be empty" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiển thị NULL không được để trống" } } } }, - "Password": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mật khẩu" + "NULL display contains invalid characters (newlines/tabs)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiển thị NULL chứa ký tự không hợp lệ (xuống dòng/tab)" } } } }, - "Paste": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Dán" + "NULL display must be %lld characters or less" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiển thị NULL phải có %lld ký tự trở xuống" } } } }, - "Paste a connection URL to auto-fill the form fields.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Dán URL kết nối để tự động điền các trường trong biểu mẫu." + "NULL display:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiển thị NULL:" } } } }, - "Paste your CREATE TABLE statement below:": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Dán câu lệnh CREATE TABLE bên dưới:" + "Nullable" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cho phép NULL" } } } }, - "Pink": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hồng" + "Number of documents per insertMany statement. Higher values create fewer statements." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số lượng document cho mỗi câu lệnh insertMany. Giá trị cao hơn tạo ít câu lệnh hơn." } } } }, - "Please select a column": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Vui lòng chọn một cột" + "Offset" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vị trí bắt đầu" } } } }, - "Port": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cổng" + "OK" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" } } } }, - "Potentially Dangerous Queries": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truy vấn có thể nguy hiểm" + "On Delete" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khi xóa" } } } }, - "Potentially Dangerous Query": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truy vấn có thể nguy hiểm" + "On Update" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khi cập nhật" } } } }, - "Precision": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Độ chính xác" + "Open" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở" } } } }, - "Precision:": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Độ chính xác:" + "Open Connection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở kết nối" } } } }, - "Preserve all values as strings": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giữ nguyên tất cả giá trị dưới dạng chuỗi" + "Open containing folder" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở thư mục chứa" } } } }, - "Preset Name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên mẫu đặt trước" + "Open database" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở cơ sở dữ liệu" } } } }, - "Pretty Print": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Định dạng đẹp" + "Open Database" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở cơ sở dữ liệu" } } } }, - "Pretty print (formatted output)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "In đẹp (đầu ra có định dạng)" + "Open Database (⌘K)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở cơ sở dữ liệu (⌘K)" } } } }, - "Prevent CSV formula injection by prefixing values starting with =, +, -, @ with a single quote": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Ngăn chặn chèn công thức CSV bằng cách thêm dấu nháy đơn trước các giá trị bắt đầu bằng =, +, -, @" + "Open Database..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở cơ sở dữ liệu..." } } } }, - "Prevent write operations (INSERT, UPDATE, DELETE, DROP, etc.)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Ngăn chặn thao tác ghi (INSERT, UPDATE, DELETE, DROP, v.v.)" + "Open MQL Editor" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở trình soạn MQL" } } } }, - "Preview": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước" + "Open Redis CLI" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở Redis CLI" } } } }, - "Preview Commands": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước lệnh" + "Open Schema" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở Schema" } } } }, - "Preview Commands (⌘⇧P)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước lệnh (⌘⇧P)" + "Open SQL Editor" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở trình soạn SQL" } } } }, - "Preview MQL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước MQL" + "Optimize Query" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tối ưu truy vấn" } } } }, - "Preview MQL (⌘⇧P)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước MQL (⌘⇧P)" + "Optimize this SQL query for better performance:\n\n```sql\n%@\n```" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đề xuất tối ưu hóa cho câu truy vấn SQL sau:\n\n```sql\n%@\n```" } } } }, - "Preview SQL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước SQL" + "Optimize with AI" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tối ưu với AI" } } } }, - "Preview SQL (⌘⇧P)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước SQL (⌘⇧P)" + "Optional description" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mô tả tùy chọn" } } } }, - "Preview Schema Changes": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước thay đổi cấu trúc" + "Options" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tùy chọn" } } } }, - "Previous Page (⌘[)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Trang trước (⌘[)" + "OR" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "OR" } } } }, - "Previous Tab": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tab trước" + "Orange" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cam" } } } }, - "Previous Tab (Alt)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tab trước (Alt)" + "Page size must be between %@ and %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Page size must be between %1$@ and %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kích thước trang phải nằm trong khoảng %1$@ đến %2$@" } } } }, - "Primary": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Primary" + "Pagination" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phân trang" } } } }, - "Primary Preferred": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Primary Preferred" + "Pagination Settings" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt phân trang" } } } }, - "Privacy": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Quyền riêng tư" + "Panel State" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trạng thái bảng điều khiển" } } } }, - "Private Key": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Khóa riêng tư" + "Parent Group" : { + "comment" : "A label for selecting a parent group in the connection group form sheet.", + "isCommentAutoGenerated" : true + }, + "Passphrase" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cụm mật khẩu" } } } }, - "Providers": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhà cung cấp" + "Password" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mật khẩu" } } } }, - "Purple": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tím" + "Paste" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dán" } } } }, - "Put field names in the first row": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đặt tên trường ở dòng đầu tiên" + "Paste a connection URL to auto-fill the form fields." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dán URL kết nối để tự động điền các trường trong biểu mẫu." } } } }, - "Query": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truy vấn" + "Paste your CREATE TABLE statement below:" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dán câu lệnh CREATE TABLE bên dưới:" } } } }, - "Query Execution": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thực thi truy vấn" + "pending delete" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chờ xóa" } } } }, - "Query Execution Failed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thực thi truy vấn thất bại" + "pending truncate" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chờ truncate" } } } }, - "Query cancelled": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã hủy truy vấn" + "Pink" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hồng" } } } }, - "Query executed successfully": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truy vấn thực thi thành công" + "Please select a column" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vui lòng chọn một cột" } } } }, - "Query executing": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang thực hiện truy vấn" + "Port" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cổng" } } } }, - "Query executing...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang thực thi truy vấn..." + "postgresql://user:password@host:5432/database" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "postgresql://user:password@host:5432/database" } } } }, - "Query timeout:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thời gian chờ truy vấn:" + "Potentially Dangerous Queries" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn có thể nguy hiểm" } } } }, - "Quick search across all columns...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tìm kiếm nhanh trên tất cả các cột..." + "Potentially Dangerous Query" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn có thể nguy hiểm" } } } }, - "Quote": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Dấu ngoặc kép" + "Precision" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Độ chính xác" } } } }, - "Quote if needed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đặt trong ngoặc kép nếu cần" + "Precision:" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Độ chính xác:" } } } }, - "RECENT": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "GẦN ĐÂY" + "Preserve all values as strings" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giữ nguyên tất cả giá trị dưới dạng chuỗi" } } } }, - "Rate limited. Please try again later.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã vượt giới hạn tốc độ. Vui lòng thử lại sau." + "Preset Name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên mẫu đặt trước" } } } }, - "Raw SQL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "SQL thô" + "Pretty Print" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Định dạng đẹp" } } } }, - "Raw SQL cannot be empty": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "SQL thô không được để trống" + "Pretty print (formatted output)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "In đẹp (đầu ra có định dạng)" } } } }, - "Read Preference": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Read Preference" + "Prevent CSV formula injection by prefixing values starting with =, +, -, @ with a single quote" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngăn chặn chèn công thức CSV bằng cách thêm dấu nháy đơn trước các giá trị bắt đầu bằng =, +, -, @" } } } }, - "Read-Only": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chỉ đọc" + "Prevent write operations (INSERT, UPDATE, DELETE, DROP, etc.)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngăn chặn thao tác ghi (INSERT, UPDATE, DELETE, DROP, v.v.)" } } } }, - "Read-only": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chỉ đọc" + "Preview" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước" } } } }, - "Read-only connection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối chỉ đọc" + "Preview Commands" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước lệnh" } } } }, - "Reassign": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Gán lại" + "Preview Commands (⌘⇧P)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước lệnh (⌘⇧P)" } } } }, - "Recent Conversations": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cuộc trò chuyện gần đây" + "Preview MQL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước MQL" } } } }, - "Reconnect": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối lại" + "Preview MQL (⌘⇧P)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước MQL (⌘⇧P)" } } } }, - "Reconnect failed: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối lại thất bại: %@" + "Preview Schema Changes" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước thay đổi cấu trúc" } } } }, - "Reconnect to Database": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối lại cơ sở dữ liệu" + "Preview SQL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước SQL" } } } }, - "Recording shortcut": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đang ghi phím tắt" + "Preview SQL (⌘⇧P)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước SQL (⌘⇧P)" } } } }, - "Red": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đỏ" + "Previous Page (⌘[)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trang trước (⌘[)" } } } }, - "Redis": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Redis" + "Previous Tab" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab trước" } } } }, - "Redo": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Làm lại" + "Previous Tab (Alt)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab trước (Alt)" } } } }, - "Ref Columns": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cột tham chiếu" + "Primary" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primary" } } } }, - "Ref Table": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bảng tham chiếu" + "Primary Preferred" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primary Preferred" } } } }, - "Referenced columns (comma-separated)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cột tham chiếu (phân tách bằng dấu phẩy)" + "Privacy" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quyền riêng tư" } } } }, - "Referenced table": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bảng tham chiếu" + "Private Key" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khóa riêng tư" } } } }, - "Refresh": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Làm mới" + "Providers" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhà cung cấp" } } } }, - "Refresh (⌘R)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Làm mới (⌘R)" + "Purple" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tím" } } } }, - "Refresh data": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Làm mới dữ liệu" + "Put field names in the first row" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt tên trường ở dòng đầu tiên" } } } }, - "Refresh database list": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Làm mới danh sách cơ sở dữ liệu" + "Query" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn" } } } }, - "Refreshing will discard all unsaved changes.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Làm mới sẽ hủy tất cả thay đổi chưa lưu." + "Query cancelled" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã hủy truy vấn" } } } }, - "Regenerate": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo lại" + "Query executed successfully" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn thực thi thành công" } } } }, - "Remove Filter": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa bộ lọc" + "Query executing" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang thực hiện truy vấn" } } } }, - "Remove filter": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xóa bộ lọc" + "Query executing..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang thực thi truy vấn..." } } } }, - "Remove license from this machine": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Gỡ giấy phép khỏi máy này" + "Query Execution" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thực thi truy vấn" } } } }, - "Rename": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đổi tên" + "Query Execution Failed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thực thi truy vấn thất bại" } } } }, - "Rename Group": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đổi tên nhóm" + "Query timeout:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian chờ truy vấn:" } } } }, - "Reopen Last Session": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở lại phiên làm việc trước" + "Quick search across all columns..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tìm kiếm nhanh trên tất cả các cột..." } } } }, - "Replication lag: %llds": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Độ trễ sao chép: %llds" + "Quote" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dấu ngoặc kép" } } } }, - "Require SSL, skip verification": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Yêu cầu SSL, bỏ qua xác minh" + "Quote if needed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt trong ngoặc kép nếu cần" } } } }, - "Reset to Defaults": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Khôi phục mặc định" + "Rate limited. Please try again later." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã vượt giới hạn tốc độ. Vui lòng thử lại sau." } } } }, - "Restart TablePro for the language change to take full effect.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Khởi động lại TablePro để thay đổi ngôn ngữ có hiệu lực hoàn toàn." + "Raw SQL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SQL thô" } } } }, - "Retention": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu giữ" + "Raw SQL cannot be empty" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SQL thô không được để trống" } } } }, - "Retry": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thử lại" + "Read Preference" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Read Preference" } } } }, - "Reuse clean table tab": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tái sử dụng tab bảng trống" + "Read-only" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chỉ đọc" } } } }, - "Right-click to show all tables": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhấn chuột phải để hiện tất cả bảng" + "Read-Only" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chỉ đọc" } } } }, - "Row %lld": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hàng %lld" + "Read-only connection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối chỉ đọc" } } } }, - "Row %lld, column %lld: %@": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "Row %1$lld, column %2$lld: %3$@" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hàng %lld, cột %lld: %@" + "Reassign" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gán lại" } } } }, - "Row Details": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chi tiết dòng" + "RECENT" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "GẦN ĐÂY" } } } }, - "Row height:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chiều cao dòng:" + "Recent Conversations" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuộc trò chuyện gần đây" } } } }, - "Row number": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Số hàng" + "Reconnect" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối lại" } } } }, - "Rows": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Dòng" + "Reconnect failed: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối lại thất bại: %@" } } } }, - "Rows per INSERT": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Dòng mỗi INSERT" + "Reconnect to Database" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối lại cơ sở dữ liệu" } } } }, - "Rows per insertMany": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Số dòng mỗi insertMany" + "Recording shortcut" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang ghi phím tắt" } } } }, - "Run a query to see execution time": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chạy truy vấn để xem thời gian thực thi" + "Red" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đỏ" } } } }, - "Run in New Tab": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chạy trong tab mới" + "Redis" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redis" } } } }, - "SAVED CONNECTIONS": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "KẾT NỐI ĐÃ LƯU" + "Redo" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Làm lại" } } } }, - "SELECT * FROM users WHERE id = 1;": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "SELECT * FROM users WHERE id = 1;" + "Ref Columns" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cột tham chiếu" } } } }, - "SIZE": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "KÍCH THƯỚC" + "Ref Table" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng tham chiếu" } } } }, - "SQL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "SQL" + "Referenced columns (comma-separated)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cột tham chiếu (phân tách bằng dấu phẩy)" } } } }, - "SQL Functions": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hàm SQL" + "Referenced table" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng tham chiếu" } } } }, - "SQL Preview": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem trước SQL" + "Refresh" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Làm mới" } } } }, - "SQL Server": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "SQL Server" + "Refresh (⌘R)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Làm mới (⌘R)" } } } }, - "SQL import is not supported for %@ connections.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập SQL không được hỗ trợ cho kết nối %@." + "Refresh data" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Làm mới dữ liệu" } } } }, - "SQLite is file-based": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "SQLite dựa trên tệp" + "Refresh database list" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Làm mới danh sách cơ sở dữ liệu" } } } }, - "SSH Host": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Máy chủ SSH" + "Refreshing will discard all unsaved changes." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Làm mới sẽ hủy tất cả thay đổi chưa lưu." } } } }, - "SSH Port": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cổng SSH" + "Regenerate" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo lại" } } } }, - "SSH Tunnel": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đường hầm SSH" + "Remove filter" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa bộ lọc" } } } }, - "SSH User": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Người dùng SSH" + "Remove Filter" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa bộ lọc" } } } }, - "SSH authentication failed. Check your credentials or private key.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xác thực SSH thất bại. Kiểm tra thông tin đăng nhập hoặc khóa riêng tư." + "Remove license from this machine" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gỡ giấy phép khỏi máy này" } } } }, - "SSH command not found. Please ensure OpenSSH is installed.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không tìm thấy lệnh SSH. Vui lòng đảm bảo OpenSSH đã được cài đặt." + "Rename" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đổi tên" } } } }, - "SSH connection timed out": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối SSH đã hết thời gian" + "Rename Group" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đổi tên nhóm" } } } }, - "SSH tunnel already exists for connection: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đường hầm SSH đã tồn tại cho kết nối: %@" + "Reopen Last Session" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mở lại phiên làm việc trước" } } } }, - "SSH tunnel creation failed: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạo đường hầm SSH thất bại: %@" + "Replication lag: %llds" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Độ trễ sao chép: %llds" } } } }, - "SSL Mode": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chế độ SSL" + "Require SSL, skip verification" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yêu cầu SSL, bỏ qua xác minh" } } } }, - "SSL/TLS": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "SSL/TLS" + "Reset to Defaults" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khôi phục mặc định" } } } }, - "STATISTICS": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "THỐNG KÊ" + "Restart TablePro for the language change to take full effect." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khởi động lại TablePro để thay đổi ngôn ngữ có hiệu lực hoàn toàn." } } } }, - "Same options will be applied to all selected tables.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cùng tùy chọn sẽ được áp dụng cho tất cả bảng đã chọn." + "Retention" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu giữ" } } } }, - "Sanitize formula-like values": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Làm sạch giá trị giống công thức" + "Retry" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thử lại" } } } }, - "Save": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu" + "Reuse clean table tab" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tái sử dụng tab bảng trống" } } } }, - "Save Anyway": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Vẫn lưu" + "Right-click to show all tables" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhấn chuột phải để hiện tất cả bảng" } } } }, - "Save Changes": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu thay đổi" + "root" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "root" } } } }, - "Save Failed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu thất bại" + "Row %lld" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hàng %lld" } } } }, - "Save Filter Preset": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu mẫu bộ lọc" + "Row %lld, column %lld: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Row %1$lld, column %2$lld: %3$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hàng %lld, cột %lld: %@" } } } }, - "Save Table Template": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu mẫu bảng" + "Row Details" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chi tiết dòng" } } } }, - "Save and load filter presets": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu và tải mẫu bộ lọc" + "Row height:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiều cao dòng:" } } } }, - "Save as Preset...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu dưới dạng mẫu..." + "Row number" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số hàng" } } } }, - "Save as Template": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lưu dưới dạng mẫu" + "Rows" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dòng" } } } }, - "Saved Connections": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kết nối đã lưu" + "Rows per INSERT" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dòng mỗi INSERT" } } } }, - "Scale": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tỉ lệ" + "Rows per insertMany" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số dòng mỗi insertMany" } } } }, - "Scale:": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tỉ lệ:" + "Run a query to see execution time" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chạy truy vấn để xem thời gian thực thi" } } } }, - "Schema": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Schema" + "Run in New Tab" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chạy trong tab mới" } } } }, - "Search databases...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tìm cơ sở dữ liệu..." + "Same options will be applied to all selected tables." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cùng tùy chọn sẽ được áp dụng cho tất cả bảng đã chọn." } } } }, - "Search for connection...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tìm kết nối..." + "Sanitize formula-like values" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Làm sạch giá trị giống công thức" } } } }, - "Search for field...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tìm trường..." + "Save" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu" } } } }, - "Search or type...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tìm kiếm hoặc nhập..." + "Save and load filter presets" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu và tải mẫu bộ lọc" } } } }, - "Search queries...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tìm truy vấn..." + "Save Anyway" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vẫn lưu" } } } }, - "Search schemas...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tìm schema..." + "Save as Preset..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu dưới dạng mẫu..." } } } }, - "Search shortcuts...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tìm phím tắt..." + "Save as Template" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu dưới dạng mẫu" } } } }, - "Search...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tìm kiếm..." + "Save Changes" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu thay đổi" } } } }, - "Second value is required for BETWEEN": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giá trị thứ hai là bắt buộc cho BETWEEN" + "Save Failed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu thất bại" } } } }, - "Secondary": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Secondary" + "Save Filter Preset" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu mẫu bộ lọc" } } } }, - "Secondary Preferred": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Secondary Preferred" + "Save Table Template" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu mẫu bảng" } } } }, - "Select All": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chọn tất cả" + "Saved Connections" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối đã lưu" } } } }, - "Select SQL File...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chọn tệp SQL..." + "SAVED CONNECTIONS" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "KẾT NỐI ĐÃ LƯU" } } } }, - "Select Tab %lld": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chọn tab %lld" + "Scale" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tỉ lệ" } } } }, - "Select a Query": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chọn một truy vấn" + "Scale:" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tỉ lệ:" } } } }, - "Select a row or table to view details": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chọn một hàng hoặc bảng để xem chi tiết" + "Schema" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schema" } } } }, - "Select a table to copy its structure:": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chọn bảng để sao chép cấu trúc:" + "Schema Switch Failed" : { + "comment" : "Title of an alert sheet that appears when switching to a different PostgreSQL schema fails.", + "isCommentAutoGenerated" : true + }, + "Schemas" : { + "comment" : "A label for a tab in the database switcher that lists schemas.", + "isCommentAutoGenerated" : true + }, + "Search databases..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tìm cơ sở dữ liệu..." } } } }, - "Select filter for %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chọn bộ lọc cho %@" + "Search for connection..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tìm kết nối..." } } } }, - "Send Message": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Gửi tin nhắn" + "Search for field..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tìm trường..." } } } }, - "Server": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Máy chủ" + "Search or type..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tìm kiếm hoặc nhập..." } } } }, - "Server error (%lld): %@": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "Server error (%1$lld): %2$@" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lỗi máy chủ (%1$lld): %2$@" + "Search queries..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tìm truy vấn..." } } } }, - "Set DEFAULT": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đặt DEFAULT" + "Search schemas..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tìm schema..." } } } }, - "Set EMPTY": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đặt TRỐNG" + "Search shortcuts..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tìm phím tắt..." } } } }, - "Set NULL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đặt NULL" + "Search..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tìm kiếm..." } } } }, - "Set Up AI Provider": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thiết lập nhà cung cấp AI" + "Second value is required for BETWEEN" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giá trị thứ hai là bắt buộc cho BETWEEN" } } } }, - "Set Value": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đặt giá trị" + "Secondary" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secondary" } } } }, - "Set special value": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đặt giá trị đặc biệt" + "Secondary Preferred" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secondary Preferred" } } } }, - "Share anonymous usage data": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chia sẻ dữ liệu sử dụng ẩn danh" + "SELECT * FROM users WHERE id = 1;" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SELECT * FROM users WHERE id = 1;" } } } }, - "Shortcut Conflict": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xung đột phím tắt" + "Select a Query" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn một truy vấn" } } } }, - "Shortcut recorder": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Ghi phím tắt" + "Select a row or table to view details" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn một hàng hoặc bảng để xem chi tiết" } } } }, - "Show All Collections": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiển thị tất cả Collection" + "Select a table to copy its structure:" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn bảng để sao chép cấu trúc:" } } } }, - "Show All Databases": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiển thị tất cả cơ sở dữ liệu" + "Select All" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn tất cả" } } } }, - "Show All Tables": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiện tất cả bảng" + "Select filter for %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn bộ lọc cho %@" } } } }, - "Show Next Tab": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiện tab tiếp" + "Select SQL File..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn tệp SQL..." } } } }, - "Show Previous Tab": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiện tab trước" + "Select Tab %lld" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn tab %lld" } } } }, - "Show Structure": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiện cấu trúc" + "Send Message" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gửi tin nhắn" } } } }, - "Show Welcome Screen": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiện màn hình chào mừng" + "Server" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Máy chủ" } } } }, - "Show Welcome Window": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiện cửa sổ chào mừng" + "Server error (%lld): %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Server error (%1$lld): %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi máy chủ (%1$lld): %2$@" } } } }, - "Show alternate row backgrounds": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiện nền xen kẽ dòng" + "Set DEFAULT" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt DEFAULT" } } } }, - "Show line numbers": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hiện số dòng" + "Set EMPTY" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt TRỐNG" } } } }, - "Size:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kích thước:" + "Set NULL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt NULL" } } } }, - "Skip": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bỏ qua" + "Set special value" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt giá trị đặc biệt" } } } }, - "Software Update": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cập nhật phần mềm" + "Set Up AI Provider" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thiết lập nhà cung cấp AI" } } } }, - "Spacious": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Rộng rãi" + "Set Value" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt giá trị" } } } }, - "Statement %lld": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Câu lệnh %lld" + "Share anonymous usage data" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chia sẻ dữ liệu sử dụng ẩn danh" } } } }, - "Statement %lld of %lld": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "Statement %1$lld of %2$lld" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Câu lệnh %1$lld / %2$lld" + "Shortcut Conflict" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xung đột phím tắt" } } } }, - "Statement:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Câu lệnh:" + "Shortcut recorder" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ghi phím tắt" } } } }, - "Stop": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Dừng" + "Show All Collections" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiển thị tất cả Collection" } } } }, - "Stop Generating": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Dừng tạo phản hồi" + "Show All Databases" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiển thị tất cả cơ sở dữ liệu" } } } }, - "Streaming failed: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phát trực tuyến thất bại: %@" + "Show All Tables" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện tất cả bảng" } } } }, - "Structure": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cấu trúc" + "Show alternate row backgrounds" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện nền xen kẽ dòng" } } } }, - "Structure, Drop, and Data options are configured per table in the table list.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tùy chọn Cấu trúc, Xóa và Dữ liệu được cấu hình cho từng bảng trong danh sách bảng." + "Show line numbers" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện số dòng" } } } }, - "Success": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thành công" + "Show Next Tab" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện tab tiếp" } } } }, - "Suspended": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạm ngưng" + "Show Previous Tab" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện tab trước" } } } }, - "Switch Connection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển kết nối" + "Show Structure" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện cấu trúc" } } } }, - "Switch Connection (⌘⌥C)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển kết nối (⌘⌥C)" + "Show Welcome Screen" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện màn hình chào mừng" } } } }, - "Switch Connection...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển kết nối..." + "Show Welcome Window" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện cửa sổ chào mừng" } } } }, - "Switch Database": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển cơ sở dữ liệu" + "SIZE" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "KÍCH THƯỚC" } } } }, - "Switch Database (⌘K)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển cơ sở dữ liệu (⌘K)" + "Size:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kích thước:" } } } }, - "Switch connection": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chuyển kết nối" + "Skip" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bỏ qua" } } } }, - "System": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hệ thống" + "Software Update" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cập nhật phần mềm" } } } }, - "System Reserved Shortcut": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phím tắt hệ thống" + "Spacious" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rộng rãi" } } } }, - "TIMESTAMPS": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "THỜI GIAN" + "SQL" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SQL" } } } }, - "TRUE": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "TRUE" + "SQL Functions" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hàm SQL" } } } }, - "Tab Behavior": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hành vi tab" + "SQL import is not supported for %@ connections." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập SQL không được hỗ trợ cho kết nối %@." } } } }, - "Tab width:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Độ rộng tab:" + "SQL Preview" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem trước SQL" } } } }, - "Table '%@' has no columns or does not exist": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bảng '%@' không có cột hoặc không tồn tại" + "SQL Server" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SQL Server" } } } }, - "Table Info": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thông tin bảng" + "SQLite is file-based" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SQLite dựa trên tệp" } } } }, - "Table Name": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên bảng" + "SSH authentication failed. Check your credentials or private key." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xác thực SSH thất bại. Kiểm tra thông tin đăng nhập hoặc khóa riêng tư." } } } }, - "Table creation options not available": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tùy chọn tạo bảng không khả dụng" + "SSH command not found. Please ensure OpenSSH is installed." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tìm thấy lệnh SSH. Vui lòng đảm bảo OpenSSH đã được cài đặt." } } } }, - "Table: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bảng: %@" + "SSH connection timed out" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối SSH đã hết thời gian" } } } }, - "TablePro": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "TablePro" + "SSH Host" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Máy chủ SSH" } } } }, - "Tables": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bảng" + "SSH Port" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cổng SSH" } } } }, - "Tablespace": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tablespace" + "SSH Tunnel" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đường hầm SSH" } } } }, - "Tabs": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tab" + "SSH tunnel already exists for connection: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đường hầm SSH đã tồn tại cho kết nối: %@" } } } }, - "Tag": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhãn" + "SSH tunnel creation failed: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo đường hầm SSH thất bại: %@" } } } }, - "Tag name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên thẻ" + "SSH User" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Người dùng SSH" } } } }, - "Tag: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thẻ: %@" + "ssh.example.com" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ssh.example.com" } } } }, - "Template": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mẫu" + "SSL Mode" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chế độ SSL" } } } }, - "Template Name": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên mẫu" + "SSL/TLS" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSL/TLS" } } } }, - "Temporarily disable foreign key constraints during import. Useful for importing data with circular dependencies.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tạm thời tắt ràng buộc khóa ngoại trong quá trình nhập. Hữu ích khi nhập dữ liệu có phụ thuộc vòng." + "starts with" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bắt đầu bằng" } } } }, - "Test": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kiểm tra" + "statement" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "câu lệnh" } } } }, - "Test Connection": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kiểm tra kết nối" + "Statement %lld" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câu lệnh %lld" } } } }, - "The following %lld queries may permanently modify or delete data. This action cannot be undone.\n\n%@": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "The following %1$lld queries may permanently modify or delete data. This action cannot be undone.\n\n%2$@" + "Statement %lld of %lld" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Statement %1$lld of %2$lld" } }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "%1$lld truy vấn sau có thể thay đổi hoặc xóa dữ liệu vĩnh viễn. Hành động này không thể hoàn tác.\n\n%2$@" + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câu lệnh %1$lld / %2$lld" } } } }, - "The text is not valid JSON. Save anyway?": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nội dung không phải JSON hợp lệ. Vẫn lưu?" + "Statement:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câu lệnh:" } } } }, - "This DELETE query has no WHERE clause and will delete ALL rows in the table. This action cannot be undone.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truy vấn DELETE này không có mệnh đề WHERE và sẽ xóa TẤT CẢ dòng trong bảng. Thao tác này không thể hoàn tác." + "statements" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "câu lệnh" } } } }, - "This DROP query will permanently remove database objects. This action cannot be undone.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truy vấn DROP này sẽ xóa vĩnh viễn các đối tượng cơ sở dữ liệu. Thao tác này không thể hoàn tác." + "STATISTICS" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "THỐNG KÊ" } } } }, - "This Month": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tháng này" + "Stop" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dừng" } } } }, - "This SQL query failed with an error. Please fix it.\n\nQuery:\n```sql\n%@\n```\n\nError: %@": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "This SQL query failed with an error. Please fix it.\n\nQuery:\n```sql\n%1$@\n```\n\nError: %2$@" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Câu truy vấn SQL sau đã thất bại với lỗi. Vui lòng sửa lỗi.\n\nTruy vấn:\n```sql\n%1$@\n```\n\nLỗi: %2$@" + "Stop Generating" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dừng tạo phản hồi" } } } }, - "This TRUNCATE query will permanently delete all rows in the table. This action cannot be undone.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truy vấn TRUNCATE này sẽ xóa vĩnh viễn tất cả dòng trong bảng. Thao tác này không thể hoàn tác." + "Streaming failed: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phát trực tuyến thất bại: %@" } } } }, - "This Week": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tuần này" + "Structure" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cấu trúc" } } } }, - "This database has no tables yet.": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Cơ sở dữ liệu này chưa có bảng nào." + "Structure, Drop, and Data options are configured per table in the table list." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tùy chọn Cấu trúc, Xóa và Dữ liệu được cấu hình cho từng bảng trong danh sách bảng." } } } }, - "This operation is not supported": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thao tác này không được hỗ trợ" + "Success" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thành công" } } } }, - "This query may permanently modify or delete data.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truy vấn này có thể sửa đổi hoặc xóa dữ liệu vĩnh viễn." + "Suspended" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạm ngưng" } } } }, - "This shortcut is reserved by macOS and cannot be assigned.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phím tắt này được macOS dành riêng và không thể gán." + "Switch connection" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển kết nối" } } } }, - "This will permanently delete %lld %@. This action cannot be undone.": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "This will permanently delete %1$lld %2$@. This action cannot be undone." - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thao tác này sẽ xóa vĩnh viễn %1$lld %2$@. Không thể hoàn tác." + "Switch Connection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển kết nối" } } } }, - "This will permanently delete all query history entries. This action cannot be undone.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thao tác này sẽ xóa vĩnh viễn toàn bộ lịch sử truy vấn. Không thể hoàn tác." + "Switch Connection (⌘⌥C)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển kết nối (⌘⌥C)" } } } }, - "This will remove the license from this machine. You can reactivate later.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thao tác này sẽ gỡ giấy phép khỏi máy này. Bạn có thể kích hoạt lại sau." + "Switch Connection..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển kết nối..." } } } }, - "Today": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hôm nay" + "Switch Database" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển cơ sở dữ liệu" } } } }, - "Toggle AI Chat": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt AI Chat" + "Switch Database (⌘K)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển cơ sở dữ liệu (⌘K)" } } } }, - "Toggle AI Chat (⌘⇧L)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt AI Chat (⌘⇧L)" + "System" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hệ thống" } } } }, - "Toggle Filters": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt bộ lọc" + "System Reserved Shortcut" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phím tắt hệ thống" } } } }, - "Toggle Filters (Cmd+F)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt bộ lọc (Cmd+F)" + "Tab Behavior" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hành vi tab" } } } }, - "Toggle Filters (⌘F)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt bộ lọc (⌘F)" + "Tab width:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Độ rộng tab:" } } } }, - "Toggle History": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt lịch sử" + "Table '%@' has no columns or does not exist" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng '%@' không có cột hoặc không tồn tại" } } } }, - "Toggle Inspector": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt thanh kiểm tra" + "Table creation options not available" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tùy chọn tạo bảng không khả dụng" } } } }, - "Toggle Inspector (⌘⌥B)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt thanh kiểm tra (⌘⌥B)" + "Table Info" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông tin bảng" } } } }, - "Toggle Query History (⌘Y)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt lịch sử truy vấn (⌘Y)" + "Table Name" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên bảng" } } } }, - "Toggle Query History (⌘⇧H)": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt lịch sử truy vấn (⌘⇧H)" + "Table: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng: %@" } } } }, - "Toggle Table Browser": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt trình duyệt bảng" + "TablePro" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro" } } } }, - "Toggle filters": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt bộ lọc" + "Tables" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng" } } } }, - "Toggle inspector": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt thanh kiểm tra" + "Tablespace" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tablespace" } } } }, - "Toggle query history": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bật/tắt lịch sử truy vấn" + "Tabs" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab" } } } }, - "Total Size": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tổng kích thước" + "Tag" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhãn" } } } }, - "Truncate": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truncate" + "Tag name" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên thẻ" } } } }, - "Truncate %lld tables": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Làm trống %lld bảng" + "Tag: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thẻ: %@" } } } }, - "Truncate Table": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Truncate bảng" + "Template" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mẫu" } } } }, - "Truncate table '%@'": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Làm trống bảng '%@'" + "Template Name" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên mẫu" } } } }, - "Try adjusting your search terms\nor date filter.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thử điều chỉnh từ khoá tìm kiếm\nhoặc bộ lọc ngày." + "Temporarily disable foreign key constraints during import. Useful for importing data with circular dependencies." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạm thời tắt ràng buộc khóa ngoại trong quá trình nhập. Hữu ích khi nhập dữ liệu có phụ thuộc vòng." } } } }, - "Type": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Kiểu" + "Test" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm tra" } } } }, - "Type shortcut...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nhập phím tắt..." + "Test Connection" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm tra kết nối" } } } }, - "UNIQUE": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "UNIQUE" + "The following %lld queries may permanently modify or delete data. This action cannot be undone.\n\n%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The following %1$lld queries may permanently modify or delete data. This action cannot be undone.\n\n%2$@" } - } - } - }, - "US Long (12/31/2024 11:59:59 PM)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mỹ dài (12/31/2024 11:59:59 PM)" + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld truy vấn sau có thể thay đổi hoặc xóa dữ liệu vĩnh viễn. Hành động này không thể hoàn tác.\n\n%2$@" } } } }, - "US Short (12/31/2024)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mỹ ngắn (12/31/2024)" + "The text is not valid JSON. Save anyway?" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nội dung không phải JSON hợp lệ. Vẫn lưu?" } } } }, - "UTC_TIMESTAMP()": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "UTC_TIMESTAMP()" + "This database has no tables yet." : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cơ sở dữ liệu này chưa có bảng nào." } } } }, - "Undo": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hoàn tác" + "This DELETE query has no WHERE clause and will delete ALL rows in the table. This action cannot be undone." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn DELETE này không có mệnh đề WHERE và sẽ xóa TẤT CẢ dòng trong bảng. Thao tác này không thể hoàn tác." } } } }, - "Undo Delete": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Hoàn tác xóa" + "This DROP query will permanently remove database objects. This action cannot be undone." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn DROP này sẽ xóa vĩnh viễn các đối tượng cơ sở dữ liệu. Thao tác này không thể hoàn tác." } } } }, - "Unique": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Duy nhất" + "This Month" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tháng này" } } } }, - "Unknown error": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lỗi không xác định" + "This operation is not supported" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thao tác này không được hỗ trợ" } } } }, - "Unlicensed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chưa có giấy phép" + "This query may permanently modify or delete data." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn này có thể sửa đổi hoặc xóa dữ liệu vĩnh viễn." } } } }, - "Unlimited": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không giới hạn" + "This shortcut is reserved by macOS and cannot be assigned." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phím tắt này được macOS dành riêng và không thể gán." } } } }, - "Unset": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bỏ đặt" + "This SQL query failed with an error. Please fix it.\n\nQuery:\n```sql\n%@\n```\n\nError: %@" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This SQL query failed with an error. Please fix it.\n\nQuery:\n```sql\n%1$@\n```\n\nError: %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câu truy vấn SQL sau đã thất bại với lỗi. Vui lòng sửa lỗi.\n\nTruy vấn:\n```sql\n%1$@\n```\n\nLỗi: %2$@" } } } }, - "Unsigned": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không dấu" + "This TRUNCATE query will permanently delete all rows in the table. This action cannot be undone." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn TRUNCATE này sẽ xóa vĩnh viễn tất cả dòng trong bảng. Thao tác này không thể hoàn tác." } } } }, - "Unsupported MongoDB method: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phương thức MongoDB không được hỗ trợ: %@" + "This Week" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tuần này" } } } }, - "Unsupported database scheme: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Scheme cơ sở dữ liệu không được hỗ trợ: %@" + "This will permanently delete %lld %@. This action cannot be undone." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This will permanently delete %1$lld %2$@. This action cannot be undone." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thao tác này sẽ xóa vĩnh viễn %1$lld %2$@. Không thể hoàn tác." } } } }, - "Untitled": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Không có tiêu đề" + "This will permanently delete all query history entries. This action cannot be undone." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thao tác này sẽ xóa vĩnh viễn toàn bộ lịch sử truy vấn. Không thể hoàn tác." } } } }, - "Updated": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Đã cập nhật" + "This will remove the license from this machine. You can reactivate later." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thao tác này sẽ gỡ giấy phép khỏi máy này. Bạn có thể kích hoạt lại sau." } } } }, - "Use Default": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mặc định" + "TIMESTAMPS" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "THỜI GIAN" } } } }, - "Use SSL if available": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Sử dụng SSL nếu có" + "to view data" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "để xem dữ liệu" } } } }, - "Username": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tên người dùng" + "Today" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hôm nay" } } } }, - "Validation Failed": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xác thực thất bại" + "Toggle AI Chat" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt AI Chat" } } } }, - "Value": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giá trị" + "Toggle AI Chat (⌘⇧L)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt AI Chat (⌘⇧L)" } } } }, - "Value is required": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Giá trị là bắt buộc" + "Toggle filters" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt bộ lọc" } } } }, - "Verify certificate and hostname": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xác minh chứng chỉ và tên máy chủ" + "Toggle Filters" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt bộ lọc" } } } }, - "Verify server certificate": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xác minh chứng chỉ máy chủ" + "Toggle Filters (⌘F)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt bộ lọc (⌘F)" } } } }, - "Version %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phiên bản %@" + "Toggle Filters (Cmd+F)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt bộ lọc (Cmd+F)" } } } }, - "Version %@ (Build %@)": { - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "Version %1$@ (Build %2$@)" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Phiên bản %1$@ (Bản dựng %2$@)" + "Toggle History" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt lịch sử" } } } }, - "View": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Xem" + "Toggle inspector" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt thanh kiểm tra" } } } }, - "View: %@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chế độ xem: %@" + "Toggle Inspector" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt thanh kiểm tra" } } } }, - "Vim mode": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chế độ Vim" + "Toggle Inspector (⌘⌥B)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt thanh kiểm tra (⌘⌥B)" } } } }, - "WARNING: Failed to re-enable foreign key checks: %@. Please manually verify FK constraints are enabled.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "CẢNH BÁO: Bật lại kiểm tra khóa ngoại thất bại: %@. Vui lòng kiểm tra thủ công rằng ràng buộc FK đã được bật." + "Toggle query history" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt lịch sử truy vấn" } } } }, - "WHERE clause...": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mệnh đề WHERE..." + "Toggle Query History (⌘⇧H)" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt lịch sử truy vấn (⌘⇧H)" } } } }, - "Website": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Trang web" + "Toggle Query History (⌘Y)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt lịch sử truy vấn (⌘Y)" } } } }, - "Welcome to TablePro": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Chào mừng đến với TablePro" + "Toggle Table Browser" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật/tắt trình duyệt bảng" } } } }, - "What you can do": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bạn có thể làm gì" + "Total Size" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tổng kích thước" } } } }, - "When TablePro starts:": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Khi TablePro khởi động:" + "true" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "true" } } } }, - "When enabled, clicking a new table replaces the current clean table tab instead of opening a new tab": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Khi bật, nhấp vào bảng mới sẽ thay thế tab bảng trống hiện tại thay vì mở tab mới" + "TRUE" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TRUE" } } } }, - "When enabled, clicking a table in the sidebar will replace the current tab if it has no unsaved changes and you haven't interacted with it (sorted, filtered, etc.).": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Khi bật, nhấp vào bảng trong thanh bên sẽ thay thế tab hiện tại nếu không có thay đổi chưa lưu và bạn chưa tương tác với nó (sắp xếp, lọc, v.v.)." + "Truncate" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truncate" } } } }, - "Word wrap": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tự động xuống dòng" + "Truncate %lld tables" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Làm trống %lld bảng" } } } }, - "Wrap in transaction (BEGIN/COMMIT)": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bọc trong giao dịch (BEGIN/COMMIT)" + "Truncate Table" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truncate bảng" } } } }, - "Write Concern": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Write Concern" + "Truncate table '%@'" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Làm trống bảng '%@'" } } } }, - "Yellow": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Vàng" + "Try adjusting your search terms\nor date filter." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thử điều chỉnh từ khoá tìm kiếm\nhoặc bộ lọc ngày." } } } }, - "You": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bạn" + "Type" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểu" } } } }, - "You can re-enable this in Settings": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bạn có thể bật lại trong Cài đặt" + "Type shortcut..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập phím tắt..." } } } }, - "You have unsaved changes to the table structure. Refreshing will discard these changes.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bạn có thay đổi chưa lưu trong cấu trúc bảng. Làm mới sẽ hủy các thay đổi này." + "Undo" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoàn tác" } } } }, - "You're all set!": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Bạn đã sẵn sàng!" + "Undo Delete" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoàn tác xóa" } } } }, - "Your database schema and query data will be sent to the AI provider for analysis. Allow for this connection?": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Lược đồ cơ sở dữ liệu và dữ liệu truy vấn sẽ được gửi đến nhà cung cấp AI để phân tích. Cho phép kết nối này?" + "Unique" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duy nhất" } } } }, - "Your executed queries will\nappear here for quick access.": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Các truy vấn đã thực thi sẽ\nxuất hiện ở đây để truy cập nhanh." + "UNIQUE" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "UNIQUE" } } } }, - "Zero Fill": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "Điền số 0" + "Unknown error" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi không xác định" } } } }, - "and": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "và" + "Unlicensed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa có giấy phép" } } } }, - "between": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "giữa" + "Unlimited" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không giới hạn" } } } }, - "contains": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "chứa" + "Unset" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bỏ đặt" } } } }, - "current": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "hiện tại" + "Unsigned" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không dấu" } } } }, - "database_name": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "database_name" + "Unsupported database scheme: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scheme cơ sở dữ liệu không được hỗ trợ: %@" } } } }, - "ends with": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "kết thúc bằng" + "Unsupported MongoDB method: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phương thức MongoDB không được hỗ trợ: %@" } } } }, - "equals": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "bằng" + "Untitled" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có tiêu đề" } } } }, - "export": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "xuất" + "Updated" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã cập nhật" } } } }, - "false": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "false" + "US Long (12/31/2024 11:59:59 PM)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mỹ dài (12/31/2024 11:59:59 PM)" } } } }, - "greater or equal": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "lớn hơn hoặc bằng" + "US Short (12/31/2024)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mỹ ngắn (12/31/2024)" } } } }, - "greater than": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "lớn hơn" + "Use Default" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mặc định" } } } }, - "history entries": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "mục lịch sử" + "Use SSL if available" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sử dụng SSL nếu có" } } } }, - "history entry": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "mục lịch sử" + "username" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "username" } } } }, - "in list": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "trong danh sách" + "Username" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên người dùng" } } } }, - "is NULL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "là NULL" + "UTC_TIMESTAMP()" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "UTC_TIMESTAMP()" } } } }, - "is empty": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "trống" + "Validation Failed" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xác thực thất bại" } } } }, - "is not NULL": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "không phải NULL" + "Value" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giá trị" } } } }, - "is not empty": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "không trống" + "Value is required" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giá trị là bắt buộc" } } } }, - "less or equal": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "nhỏ hơn hoặc bằng" + "Verify certificate and hostname" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xác minh chứng chỉ và tên máy chủ" } } } }, - "less than": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "nhỏ hơn" + "Verify server certificate" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xác minh chứng chỉ máy chủ" } } } }, - "localhost": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "localhost" + "Version %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phiên bản %@" } } } }, - "matches regex": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "khớp regex" + "Version %@ (Build %@)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Version %1$@ (Build %2$@)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phiên bản %1$@ (Bản dựng %2$@)" } } } }, - "not contains": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "không chứa" + "View" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xem" } } } }, - "not equals": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "không bằng" + "View: %@" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chế độ xem: %@" } } } }, - "not in list": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "không trong danh sách" + "Vim mode" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chế độ Vim" } } } }, - "pending delete": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "chờ xóa" + "WARNING: Failed to re-enable foreign key checks: %@. Please manually verify FK constraints are enabled." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CẢNH BÁO: Bật lại kiểm tra khóa ngoại thất bại: %@. Vui lòng kiểm tra thủ công rằng ràng buộc FK đã được bật." } } } }, - "pending truncate": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "chờ truncate" + "Website" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trang web" } } } }, - "postgresql://user:password@host:5432/database": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "postgresql://user:password@host:5432/database" + "Welcome to TablePro" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chào mừng đến với TablePro" } } } }, - "root": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "root" + "What you can do" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn có thể làm gì" } } } }, - "ssh.example.com": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "ssh.example.com" + "When enabled, clicking a new table replaces the current clean table tab instead of opening a new tab" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khi bật, nhấp vào bảng mới sẽ thay thế tab bảng trống hiện tại thay vì mở tab mới" } } } }, - "starts with": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "bắt đầu bằng" + "When enabled, clicking a table in the sidebar will replace the current tab if it has no unsaved changes and you haven't interacted with it (sorted, filtered, etc.)." : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khi bật, nhấp vào bảng trong thanh bên sẽ thay thế tab hiện tại nếu không có thay đổi chưa lưu và bạn chưa tương tác với nó (sắp xếp, lọc, v.v.)." } } } }, - "statement": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "câu lệnh" + "When TablePro starts:" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khi TablePro khởi động:" } } } }, - "statements": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "câu lệnh" + "WHERE clause..." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mệnh đề WHERE..." } } } }, - "to view data": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "để xem dữ liệu" + "Word wrap" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự động xuống dòng" } } } }, - "true": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "true" + "Wrap in transaction (BEGIN/COMMIT)" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bọc trong giao dịch (BEGIN/COMMIT)" } } } }, - "username": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "username" + "Write Concern" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Write Concern" } } } }, - "~/.ssh/id_rsa": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "~/.ssh/id_rsa" + "Yellow" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vàng" } } } }, - "© 2026 Ngo Quoc Dat.\n%@": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "© 2026 Ngo Quoc Dat.\n%@" + "You" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn" } } } }, - "·": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "·" + "You can re-enable this in Settings" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn có thể bật lại trong Cài đặt" } } } }, - "—": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "—" + "You have unsaved changes to the table structure. Refreshing will discard these changes." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn có thay đổi chưa lưu trong cấu trúc bảng. Làm mới sẽ hủy các thay đổi này." } } } }, - "•": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "•" + "You're all set!" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn đã sẵn sàng!" } } } }, - "••••••••": { - "extractionState": "stale", - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "••••••••" + "Your database schema and query data will be sent to the AI provider for analysis. Allow for this connection?" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lược đồ cơ sở dữ liệu và dữ liệu truy vấn sẽ được gửi đến nhà cung cấp AI để phân tích. Cho phép kết nối này?" } } } }, - "⌘K": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "⌘K" + "Your executed queries will\nappear here for quick access." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Các truy vấn đã thực thi sẽ\nxuất hiện ở đây để truy cập nhanh." } } } }, - "⌘T": { - "localizations": { - "vi": { - "stringUnit": { - "state": "translated", - "value": "⌘T" + "Zero Fill" : { + "extractionState" : "stale", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Điền số 0" } } } } }, - "version": "1.0" -} + "version" : "1.1" +} \ No newline at end of file From deb5c350710d94f9899ec6f9dffd93e65749717f Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:06:35 +0700 Subject: [PATCH 12/16] fix: regenerate Localizable.xcstrings after merge --- TablePro/Resources/Localizable.xcstrings | 106 ++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index fa0b96e4..37eae5aa 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -63,6 +63,7 @@ } }, "(%@)" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -319,6 +320,10 @@ } } }, + "%lld connection(s) will be permanently deleted. This cannot be undone." : { + "comment" : "A message in the second step of a confirmation dialog that informs the user about the number of affected connections and that it cannot be undone. The argument is the number of affected connections.", + "isCommentAutoGenerated" : true + }, "%lld in · %lld out" : { "localizations" : { "en" : { @@ -1533,6 +1538,7 @@ } }, "Change Color" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2514,6 +2520,7 @@ } }, "Create New Group" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -3069,6 +3076,21 @@ } } } + }, + "Delete \"%@\" and its %lld connection(s)?" : { + "comment" : "A confirmation prompt asking whether to delete a single group and all of its connections. The placeholder inside the parentheses should be replaced with the name of the group.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete \"%1$@\" and its %2$lld connection(s)?" + } + } + } + }, + "Delete \"%@\"?" : { + }, "Delete (⌫)" : { "extractionState" : "stale", @@ -3081,6 +3103,52 @@ } } }, + "Delete %lld Connection(s)" : { + "comment" : "A button title that deletes the affected connections. The argument is the count of affected connections.", + "isCommentAutoGenerated" : true + }, + "Delete %lld Connections" : { + + }, + "Delete %lld connections?" : { + "comment" : "A message in a confirmation dialog that asks the user to confirm deleting multiple connections.", + "isCommentAutoGenerated" : true + }, + "Delete %lld group(s) and %lld connection(s) total?" : { + "comment" : "A confirmation prompt asking the user to confirm deleting multiple groups and connections. The first argument is the count of groups. The second argument is the count of connections.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete %1$lld group(s) and %2$lld connection(s) total?" + } + } + } + }, + "Delete %lld Groups" : { + + }, + "Delete %lld groups and %lld connection(s) inside?" : { + "comment" : "A confirmation prompt asking whether to delete multiple groups and their connections.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete %1$lld groups and %2$lld connection(s) inside?" + } + } + } + }, + "Delete %lld groups?" : { + "comment" : "A confirmation prompt for deleting multiple groups.", + "isCommentAutoGenerated" : true + }, + "Delete %lld Items" : { + "comment" : "A title for a menu item that deletes multiple items (either connections or groups). The number in the title corresponds to the total number of items to be deleted.", + "isCommentAutoGenerated" : true + }, "Delete Check Constraint" : { "extractionState" : "stale", "localizations" : { @@ -3113,6 +3181,10 @@ } } }, + "Delete Connections" : { + "comment" : "A button that deletes a single connection.", + "isCommentAutoGenerated" : true + }, "Delete Foreign Key" : { "extractionState" : "stale", "localizations" : { @@ -3134,6 +3206,10 @@ } } }, + "Delete Groups" : { + "comment" : "The title of a button that deletes multiple groups.", + "isCommentAutoGenerated" : true + }, "Delete Index" : { "extractionState" : "stale", "localizations" : { @@ -3427,6 +3503,12 @@ } } } + }, + "Edit Group" : { + + }, + "Edit Group..." : { + }, "Edit Provider" : { "localizations" : { @@ -3601,6 +3683,7 @@ } }, "Enter a new name for the group." : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -5366,6 +5449,10 @@ } } }, + "Manage Groups" : { + "comment" : "A menu title that allows users to manage their connection groups.", + "isCommentAutoGenerated" : true + }, "Manage Tags" : { "localizations" : { "vi" : { @@ -5548,6 +5635,10 @@ } } }, + "Move to Group" : { + "comment" : "Menu item title that triggers a submenu for moving a connection to a different group.", + "isCommentAutoGenerated" : true + }, "Move Up" : { "extractionState" : "stale", "localizations" : { @@ -5710,6 +5801,10 @@ } } }, + "New Group..." : { + "comment" : "Title of a menu item in the context menu that creates a new group.", + "isCommentAutoGenerated" : true + }, "New query tab" : { "extractionState" : "stale", "localizations" : { @@ -5742,6 +5837,10 @@ } } }, + "New Subgroup..." : { + "comment" : "Title of a menu item in the context menu that allows creating a new subgroup within an existing group.", + "isCommentAutoGenerated" : true + }, "New Tab" : { "localizations" : { "vi" : { @@ -6626,6 +6725,9 @@ } } } + }, + "Parent Group" : { + }, "Passphrase" : { "localizations" : { @@ -7436,6 +7538,7 @@ } }, "Rename" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -7446,6 +7549,7 @@ } }, "Rename Group" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -9861,5 +9965,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file From 4c71fc09f0f99809d49e80dd02a8848534c68ded Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Mar 2026 13:28:28 +0700 Subject: [PATCH 13/16] fix: address PR review issues in connection groups - GroupStorage.addGroup returns Bool; duplicate check scoped to siblings - ConnectionGroupPicker delete now requires confirmation dialog - acceptGroupDrop adjusts childIndex for mixed group/connection children - isDragging set only after validation passes in drag source - totalConnectionCount guards against circular parentGroupId cycles - duplicateConnection returns placed copy with correct sortOrder - 2-step delete step2 only shown for group-resident connections - Remove duplicated collectDescendantIds; reuse GroupStorage method - Remove unnecessary comments in WelcomeWindowView --- TablePro/Core/Storage/ConnectionStorage.swift | 2 +- TablePro/Core/Storage/GroupStorage.swift | 17 +++++--- .../Connection/ConnectionGroupFormSheet.swift | 11 +---- .../Connection/ConnectionGroupPicker.swift | 26 ++++++++++-- .../WelcomeWindow/ConnectionOutlineView.swift | 31 +++++++------- TablePro/Views/WelcomeWindowView.swift | 40 +++++-------------- 6 files changed, 64 insertions(+), 63 deletions(-) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index b8541aa6..5150ea9e 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -154,7 +154,7 @@ final class ConnectionStorage { saveKeyPassphrase(keyPassphrase, for: newId) } - return duplicate + return placed } // MARK: - Keychain (Password Storage) diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index 2be35857..6fe90ee0 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -45,15 +45,22 @@ final class GroupStorage { } } - /// Add a new group (rejects case-insensitive duplicate names) - func addGroup(_ group: ConnectionGroup) { + /// Add a new group (rejects case-insensitive duplicate names among siblings). + /// Returns `true` if the group was added, `false` if a sibling with the same name exists. + @discardableResult + func addGroup(_ group: ConnectionGroup) -> Bool { var groups = loadGroups() - if groups.contains(where: { $0.name.caseInsensitiveCompare(group.name) == .orderedSame }) { + let hasDuplicate = groups.contains { + $0.parentGroupId == group.parentGroupId + && $0.name.caseInsensitiveCompare(group.name) == .orderedSame + } + if hasDuplicate { Self.logger.debug("Ignoring attempt to add duplicate group name: \(group.name, privacy: .public)") - return + return false } groups.append(group) saveGroups(groups) + return true } /// Update an existing group @@ -152,7 +159,7 @@ final class GroupStorage { // MARK: - Helpers /// Recursively collect all descendant group IDs - private func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set { + func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set { var result = Set() let children = groups.filter { $0.parentGroupId == groupId } for child in children { diff --git a/TablePro/Views/Connection/ConnectionGroupFormSheet.swift b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift index 8ed96a89..a770110f 100644 --- a/TablePro/Views/Connection/ConnectionGroupFormSheet.swift +++ b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift @@ -33,7 +33,7 @@ struct ConnectionGroupFormSheet: View { private var availableGroups: [ConnectionGroup] { let allGroups = groupStorage.loadGroups() guard let editingGroup = group else { return allGroups } - let excludedIds = collectDescendantIds(of: editingGroup.id, in: allGroups) + let excludedIds = groupStorage.collectDescendantIds(of: editingGroup.id, in: allGroups) .union([editingGroup.id]) return allGroups.filter { !excludedIds.contains($0.id) } } @@ -121,15 +121,6 @@ struct ConnectionGroupFormSheet: View { dismiss() } - private func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set { - var result = Set() - let children = groups.filter { $0.parentGroupId == groupId } - for child in children { - result.insert(child.id) - result.formUnion(collectDescendantIds(of: child.id, in: groups)) - } - return result - } } // MARK: - Parent Group Picker diff --git a/TablePro/Views/Connection/ConnectionGroupPicker.swift b/TablePro/Views/Connection/ConnectionGroupPicker.swift index d65860a0..e8d27b43 100644 --- a/TablePro/Views/Connection/ConnectionGroupPicker.swift +++ b/TablePro/Views/Connection/ConnectionGroupPicker.swift @@ -10,6 +10,8 @@ struct ConnectionGroupPicker: View { @Binding var selectedGroupId: UUID? @State private var allGroups: [ConnectionGroup] = [] @State private var showingCreateSheet = false + @State private var showingDeleteConfirmation = false + @State private var groupToDelete: ConnectionGroup? private let groupStorage = GroupStorage.shared @@ -61,7 +63,8 @@ struct ConnectionGroupPicker: View { Menu("Manage Groups") { ForEach(allGroups) { group in Button(role: .destructive) { - deleteGroup(group) + groupToDelete = group + showingDeleteConfirmation = true } label: { Label("Delete \"\(group.name)\"", systemImage: "trash") } @@ -87,11 +90,28 @@ struct ConnectionGroupPicker: View { .task { allGroups = groupStorage.loadGroups() } .sheet(isPresented: $showingCreateSheet) { ConnectionGroupFormSheet { newGroup in - groupStorage.addGroup(newGroup) - selectedGroupId = newGroup.id + if groupStorage.addGroup(newGroup) { + selectedGroupId = newGroup.id + } allGroups = groupStorage.loadGroups() } } + .confirmationDialog( + String(localized: "Delete Group"), + isPresented: $showingDeleteConfirmation, + presenting: groupToDelete + ) { group in + Button(String(localized: "Delete"), role: .destructive) { + deleteGroup(group) + } + } message: { group in + let count = groupStorage.connectionCount(for: group) + if count > 0 { + Text("Delete \"\(group.name)\" and its \(count) connection(s)?") + } else { + Text("Delete \"\(group.name)\"?") + } + } } // MARK: - Helpers diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift index 6a1c51a6..8f8f43e6 100644 --- a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -655,23 +655,16 @@ extension ConnectionOutlineView { // Disable drag in search mode guard !isSearchMode else { return nil } - isDragging = true - // On first item of a drag, capture all selected IDs - if draggedItemIds.isEmpty { + if draggedItemIds.isEmpty, !isDragging { var hasGroup = false - var hasConnection = false for row in outlineView.selectedRowIndexes { if outlineView.item(atRow: row) is OutlineGroup { hasGroup = true - } else if outlineView.item(atRow: row) is OutlineConnection { - hasConnection = true } } - // Don't allow multi-drag when groups are involved (moving a parent already moves children) let isMultiSelection = outlineView.selectedRowIndexes.count > 1 if isMultiSelection, hasGroup { - isDragging = false return nil } @@ -680,10 +673,10 @@ extension ConnectionOutlineView { draggedItemIds.insert(conn.connection.id) } } + isDragging = true } - // If multi-drag was blocked, reject subsequent items too - if !isDragging { return nil } + guard isDragging else { return nil } let pasteboardItem = NSPasteboardItem() if let outlineGroup = item as? OutlineGroup { @@ -1013,9 +1006,13 @@ extension ConnectionOutlineView { .filter { $0.parentGroupId == newParentId && $0.id != group.id } .sorted { $0.sortOrder < $1.sortOrder } + // Adjust childIndex: NSOutlineView counts both groups and connections + let childConns = childrenMap[newParentId]?.compactMap { $0 as? OutlineConnection } ?? [] + let groupIndex = max(0, childIndex - childConns.count) + var movedGroup = group movedGroup.parentGroupId = newParentId - siblings.insert(movedGroup, at: min(childIndex, siblings.count)) + siblings.insert(movedGroup, at: min(groupIndex, siblings.count)) for (order, var g) in siblings.enumerated() { g.sortOrder = order @@ -1042,9 +1039,13 @@ extension ConnectionOutlineView { .filter { $0.parentGroupId == nil && $0.id != group.id } .sorted { $0.sortOrder < $1.sortOrder } + // Adjust childIndex: root items mix groups and connections + let rootConnCount = rootItems.compactMap { $0 as? OutlineConnection }.count + let groupIndex = max(0, childIndex - rootConnCount) + var movedGroup = group movedGroup.parentGroupId = nil - rootGroupSiblings.insert(movedGroup, at: min(childIndex, rootGroupSiblings.count)) + rootGroupSiblings.insert(movedGroup, at: min(groupIndex, rootGroupSiblings.count)) for (order, var g) in rootGroupSiblings.enumerated() { g.sortOrder = order @@ -1119,10 +1120,12 @@ extension ConnectionOutlineView { } } - private func totalConnectionCount(for groupId: UUID) -> Int { + private func totalConnectionCount(for groupId: UUID, visited: Set = []) -> Int { + guard !visited.contains(groupId) else { return 0 } let directConns = parent.connections.filter { $0.groupId == groupId }.count let childGroupIds = parent.groups.filter { $0.parentGroupId == groupId }.map(\.id) - let nested = childGroupIds.reduce(0) { $0 + totalConnectionCount(for: $1) } + let nextVisited = visited.union([groupId]) + let nested = childGroupIds.reduce(0) { $0 + totalConnectionCount(for: $1, visited: nextVisited) } return directConns + nested } diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index e7231b92..8dbe1f0c 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -22,12 +22,10 @@ struct WelcomeWindowView: View { @State private var searchText = "" @State private var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() - // Group state @State private var groups: [ConnectionGroup] = [] @State private var expandedGroups: Set = [] @State private var groupFormContext: GroupFormContext? - // Delete confirmation (2-step for non-empty groups) @State private var pendingDelete = PendingDelete() @State private var showDeleteStep1 = false @State private var showDeleteStep2 = false @@ -112,8 +110,7 @@ struct WelcomeWindowView: View { ) { group in if context.group != nil { groupStorage.updateGroup(group) - } else { - groupStorage.addGroup(group) + } else if groupStorage.addGroup(group) { expandedGroups.insert(group.id) groupStorage.saveExpandedGroupIds(expandedGroups) } @@ -286,8 +283,7 @@ struct WelcomeWindowView: View { allConns[index] = updated } } - // Append any new entries (e.g. moved from another group) - for conn in reorderedConns where !allConns.contains(where: { $0.id == conn.id }) { + for conn in reorderedConns where !allConns.contains(where: { $0.id == conn.id }) { allConns.append(conn) } storage.saveConnections(allConns) @@ -397,11 +393,9 @@ struct WelcomeWindowView: View { } private func connectToDatabase(_ connection: DatabaseConnection) { - // Open main window first, then connect in background openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) NSApplication.shared.closeWindows(withId: "welcome") - // Connect in background - main window shows loading state Task { do { try await dbManager.connectToSession(connection) @@ -427,13 +421,8 @@ struct WelcomeWindowView: View { private func duplicateConnection(_ connection: DatabaseConnection) { - // Create duplicate with new UUID and copy passwords let duplicate = storage.duplicateConnection(connection) - - // Refresh connections list loadConnections() - - // Open edit form for the duplicate so user can rename openWindow(id: "connection-form", value: duplicate.id as UUID?) focusConnectionFormWindow() } @@ -465,7 +454,7 @@ struct WelcomeWindowView: View { private func deleteGroup(_ group: ConnectionGroup) { let allGroups = groupStorage.loadGroups() - let descendantIds = collectDescendantIds(of: group.id, in: allGroups) + let descendantIds = groupStorage.collectDescendantIds(of: group.id, in: allGroups) let allDeletedIds = descendantIds.union([group.id]) groupStorage.deleteGroup(group) expandedGroups.subtract(allDeletedIds) @@ -479,38 +468,29 @@ struct WelcomeWindowView: View { loadConnections() } - private func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set { - var result = Set() - let children = groups.filter { $0.parentGroupId == groupId } - for child in children { - result.insert(child.id) - result.formUnion(collectDescendantIds(of: child.id, in: groups)) - } - return result - } - private func requestDelete(groups: [ConnectionGroup] = [], connections: [DatabaseConnection] = []) { // Collect all group IDs being deleted (including descendants) let allGroups = groupStorage.loadGroups() var deletedGroupIds = Set() for group in groups { deletedGroupIds.insert(group.id) - deletedGroupIds.formUnion(collectDescendantIds(of: group.id, in: allGroups)) + deletedGroupIds.formUnion(groupStorage.collectDescendantIds(of: group.id, in: allGroups)) } - // Collect all affected connections: explicitly selected + inside deleted groups + // Count connections inside deleted groups (excluding explicitly selected ones) let allConnections = storage.loadConnections() - var affectedIds = Set(connections.map(\.id)) + let explicitIds = Set(connections.map(\.id)) + var groupResidentCount = 0 for conn in allConnections { - if let gid = conn.groupId, deletedGroupIds.contains(gid) { - affectedIds.insert(conn.id) + if let gid = conn.groupId, deletedGroupIds.contains(gid), !explicitIds.contains(conn.id) { + groupResidentCount += 1 } } pendingDelete = PendingDelete( groups: groups, connections: connections, - affectedConnectionCount: affectedIds.count + affectedConnectionCount: groupResidentCount ) showDeleteStep1 = true } From f96489fd72723a90d77b30fcba4c2a405fb08184 Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:34:32 +0700 Subject: [PATCH 14/16] fix: sortOrder migration for legacy data, multi-drag position, and form sheet perf --- TablePro/Core/Storage/ConnectionStorage.swift | 14 +++++++++- TablePro/Core/Storage/GroupStorage.swift | 15 ++++++++++- .../Connection/ConnectionGroupFormSheet.swift | 3 ++- .../WelcomeWindow/ConnectionOutlineView.swift | 27 ++++++++++++++----- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 5150ea9e..86bdae7a 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -37,9 +37,10 @@ final class ConnectionStorage { do { let storedConnections = try decoder.decode([StoredConnection].self, from: data) - let connections = storedConnections.map { stored in + var connections = storedConnections.map { stored in stored.toConnection() } + migrateSortOrderIfNeeded(&connections) cachedConnections = connections return connections } catch { @@ -62,6 +63,17 @@ final class ConnectionStorage { } } + /// Assign sequential sortOrder when all items have default 0 (legacy migration). + private func migrateSortOrderIfNeeded(_ connections: inout [DatabaseConnection]) { + guard connections.count > 1, connections.allSatisfy({ $0.sortOrder == 0 }) else { return } + for index in connections.indices { + connections[index].sortOrder = index + } + saveConnections(connections) + let count = connections.count + Self.logger.info("Migrated sortOrder for \(count) connections") + } + /// Add a new connection func addConnection(_ connection: DatabaseConnection, password: String? = nil) { var connections = loadConnections() diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index 6fe90ee0..4a2cd673 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -28,13 +28,26 @@ final class GroupStorage { } do { - return try decoder.decode([ConnectionGroup].self, from: data) + var groups = try decoder.decode([ConnectionGroup].self, from: data) + migrateSortOrderIfNeeded(&groups) + return groups } catch { Self.logger.error("Failed to load groups: \(error)") return [] } } + /// Assign sequential sortOrder when all items have default 0 (legacy migration). + private func migrateSortOrderIfNeeded(_ groups: inout [ConnectionGroup]) { + guard groups.count > 1, groups.allSatisfy({ $0.sortOrder == 0 }) else { return } + for index in groups.indices { + groups[index].sortOrder = index + } + saveGroups(groups) + let count = groups.count + Self.logger.info("Migrated sortOrder for \(count) groups") + } + /// Save all groups func saveGroups(_ groups: [ConnectionGroup]) { do { diff --git a/TablePro/Views/Connection/ConnectionGroupFormSheet.swift b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift index a770110f..fa646f22 100644 --- a/TablePro/Views/Connection/ConnectionGroupFormSheet.swift +++ b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift @@ -16,6 +16,7 @@ struct ConnectionGroupFormSheet: View { @State private var name: String = "" @State private var color: ConnectionColor = .blue @State private var selectedParentId: UUID? + @State private var allGroups: [ConnectionGroup] = [] private let groupStorage = GroupStorage.shared @@ -31,7 +32,6 @@ struct ConnectionGroupFormSheet: View { /// All groups excluding self and descendants when editing private var availableGroups: [ConnectionGroup] { - let allGroups = groupStorage.loadGroups() guard let editingGroup = group else { return allGroups } let excludedIds = groupStorage.collectDescendantIds(of: editingGroup.id, in: allGroups) .union([editingGroup.id]) @@ -80,6 +80,7 @@ struct ConnectionGroupFormSheet: View { } .frame(width: 360) .onAppear { + allGroups = groupStorage.loadGroups() if let group { name = group.name color = group.color diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift index 8f8f43e6..c481d016 100644 --- a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -949,7 +949,6 @@ extension ConnectionOutlineView { targetItem: Any?, childIndex: Int ) -> Bool { - // Single connection: use existing logic if connections.count == 1 { return acceptConnectionDrop( connection: connections[0], @@ -961,16 +960,30 @@ extension ConnectionOutlineView { let draggedIds = Set(connections.map(\.id)) let targetGroupId: UUID? = (targetItem as? OutlineGroup)?.group.id - // Get existing siblings excluding dragged items var siblings = parent.connections .filter { $0.groupId == targetGroupId && !draggedIds.contains($0.id) } .sorted { $0.sortOrder < $1.sortOrder } - // Append all dragged connections at the end - for var conn in connections { - conn.groupId = targetGroupId - conn.sortOrder = (siblings.last?.sortOrder ?? -1) + 1 - siblings.append(conn) + // Prepare dragged connections with updated groupId + let movedConns: [DatabaseConnection] = connections.map { conn in + var moved = conn + moved.groupId = targetGroupId + return moved + } + + if childIndex == NSOutlineViewDropOnItemIndex { + // Dropped ON target: append at end + siblings.append(contentsOf: movedConns) + } else { + // Dropped at specific position: compute insertion index + let groupCount: Int + if let targetGroup = targetItem as? OutlineGroup { + groupCount = childrenMap[targetGroup.group.id]?.compactMap({ $0 as? OutlineGroup }).count ?? 0 + } else { + groupCount = rootItems.compactMap { $0 as? OutlineGroup }.count + } + let insertAt = min(max(0, childIndex - groupCount), siblings.count) + siblings.insert(contentsOf: movedConns, at: insertAt) } // Renumber From 864f32a5ac15550c30774184b442f05b99612e82 Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:49:11 +0700 Subject: [PATCH 15/16] fix: group drag-drop position when reordering after last group --- .../WelcomeWindow/ConnectionOutlineView.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift index c481d016..ac53843d 100644 --- a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -1019,13 +1019,13 @@ extension ConnectionOutlineView { .filter { $0.parentGroupId == newParentId && $0.id != group.id } .sorted { $0.sortOrder < $1.sortOrder } - // Adjust childIndex: NSOutlineView counts both groups and connections - let childConns = childrenMap[newParentId]?.compactMap { $0 as? OutlineConnection } ?? [] - let groupIndex = max(0, childIndex - childConns.count) + // childIndex is in childrenMap order (groups first, then connections). + // Clamp to sibling group count since we only reorder among groups. + let groupIndex = min(childIndex, siblings.count) var movedGroup = group movedGroup.parentGroupId = newParentId - siblings.insert(movedGroup, at: min(groupIndex, siblings.count)) + siblings.insert(movedGroup, at: groupIndex) for (order, var g) in siblings.enumerated() { g.sortOrder = order @@ -1052,13 +1052,13 @@ extension ConnectionOutlineView { .filter { $0.parentGroupId == nil && $0.id != group.id } .sorted { $0.sortOrder < $1.sortOrder } - // Adjust childIndex: root items mix groups and connections - let rootConnCount = rootItems.compactMap { $0 as? OutlineConnection }.count - let groupIndex = max(0, childIndex - rootConnCount) + // childIndex is in rootItems order (groups first, then connections). + // Clamp to group count since we only reorder among groups. + let groupIndex = min(childIndex, rootGroupSiblings.count) var movedGroup = group movedGroup.parentGroupId = nil - rootGroupSiblings.insert(movedGroup, at: min(groupIndex, rootGroupSiblings.count)) + rootGroupSiblings.insert(movedGroup, at: groupIndex) for (order, var g) in rootGroupSiblings.enumerated() { g.sortOrder = order From bd1effed09a746e1b5d69f3deb2fc3ba842f1acd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Mar 2026 18:08:12 +0700 Subject: [PATCH 16/16] fix: correct drag-drop reorder position when moving items forward --- .../WelcomeWindow/ConnectionOutlineView.swift | 80 ++++++++++++++----- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift index ac53843d..c0f816ec 100644 --- a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -889,13 +889,23 @@ extension ConnectionOutlineView { parent.onReorderConnections?(siblings) } else { // Dropped at a specific index within the group - var siblings = parent.connections - .filter { $0.groupId == targetGroupId && $0.id != connection.id } + let allGroupConns = parent.connections + .filter { $0.groupId == targetGroupId } .sorted { $0.sortOrder < $1.sortOrder } let childGroups = childrenMap[targetGroupId]?.compactMap { $0 as? OutlineGroup } ?? [] - let connectionIndex = max(0, childIndex - childGroups.count) + var connectionIndex = max(0, childIndex - childGroups.count) + + // NSOutlineView childIndex includes the dragged item in its original position. + // When reordering within the same group, adjust for the item's removal. + if connection.groupId == targetGroupId, + let origPosition = allGroupConns.firstIndex(where: { $0.id == connection.id }), + origPosition < connectionIndex + { + connectionIndex -= 1 + } + var siblings = allGroupConns.filter { $0.id != connection.id } var movedConn = connection movedConn.groupId = targetGroupId siblings.insert(movedConn, at: min(connectionIndex, siblings.count)) @@ -921,13 +931,23 @@ extension ConnectionOutlineView { rootConns.append(movedConn) parent.onReorderConnections?(rootConns) } else { - var rootConns = parent.connections - .filter { $0.groupId == nil && $0.id != connection.id } + let allRootConns = parent.connections + .filter { $0.groupId == nil } .sorted { $0.sortOrder < $1.sortOrder } let rootGroupCount = rootItems.compactMap { $0 as? OutlineGroup }.count - let connectionIndex = max(0, childIndex - rootGroupCount) + var connectionIndex = max(0, childIndex - rootGroupCount) + + // NSOutlineView childIndex includes the dragged item in its original position. + // When reordering within root, adjust for the item's removal. + if connection.groupId == nil, + let origPosition = allRootConns.firstIndex(where: { $0.id == connection.id }), + origPosition < connectionIndex + { + connectionIndex -= 1 + } + var rootConns = allRootConns.filter { $0.id != connection.id } var movedConn = connection movedConn.groupId = nil rootConns.insert(movedConn, at: min(connectionIndex, rootConns.count)) @@ -960,9 +980,10 @@ extension ConnectionOutlineView { let draggedIds = Set(connections.map(\.id)) let targetGroupId: UUID? = (targetItem as? OutlineGroup)?.group.id - var siblings = parent.connections - .filter { $0.groupId == targetGroupId && !draggedIds.contains($0.id) } + let allTargetConns = parent.connections + .filter { $0.groupId == targetGroupId } .sorted { $0.sortOrder < $1.sortOrder } + var siblings = allTargetConns.filter { !draggedIds.contains($0.id) } // Prepare dragged connections with updated groupId let movedConns: [DatabaseConnection] = connections.map { conn in @@ -982,8 +1003,13 @@ extension ConnectionOutlineView { } else { groupCount = rootItems.compactMap { $0 as? OutlineGroup }.count } - let insertAt = min(max(0, childIndex - groupCount), siblings.count) - siblings.insert(contentsOf: movedConns, at: insertAt) + var insertAt = max(0, childIndex - groupCount) + + // Adjust for dragged items that were in the same container before the drop point + let draggedBefore = allTargetConns.prefix(insertAt).filter { draggedIds.contains($0.id) }.count + insertAt -= draggedBefore + + siblings.insert(contentsOf: movedConns, at: min(insertAt, siblings.count)) } // Renumber @@ -1015,17 +1041,26 @@ extension ConnectionOutlineView { siblings.append(movedGroup) parent.onReorderGroups?(siblings) } else { - var siblings = parent.groups - .filter { $0.parentGroupId == newParentId && $0.id != group.id } + let allSiblingGroups = parent.groups + .filter { $0.parentGroupId == newParentId } .sorted { $0.sortOrder < $1.sortOrder } // childIndex is in childrenMap order (groups first, then connections). // Clamp to sibling group count since we only reorder among groups. - let groupIndex = min(childIndex, siblings.count) + var groupIndex = min(childIndex, allSiblingGroups.count) + + // Adjust for the dragged group's original position + if group.parentGroupId == newParentId, + let origPosition = allSiblingGroups.firstIndex(where: { $0.id == group.id }), + origPosition < groupIndex + { + groupIndex -= 1 + } + var siblings = allSiblingGroups.filter { $0.id != group.id } var movedGroup = group movedGroup.parentGroupId = newParentId - siblings.insert(movedGroup, at: groupIndex) + siblings.insert(movedGroup, at: min(groupIndex, siblings.count)) for (order, var g) in siblings.enumerated() { g.sortOrder = order @@ -1048,17 +1083,26 @@ extension ConnectionOutlineView { rootGroupSiblings.append(movedGroup) parent.onReorderGroups?(rootGroupSiblings) } else { - var rootGroupSiblings = parent.groups - .filter { $0.parentGroupId == nil && $0.id != group.id } + let allRootGroups = parent.groups + .filter { $0.parentGroupId == nil } .sorted { $0.sortOrder < $1.sortOrder } // childIndex is in rootItems order (groups first, then connections). // Clamp to group count since we only reorder among groups. - let groupIndex = min(childIndex, rootGroupSiblings.count) + var groupIndex = min(childIndex, allRootGroups.count) + + // Adjust for the dragged group's original position + if group.parentGroupId == nil, + let origPosition = allRootGroups.firstIndex(where: { $0.id == group.id }), + origPosition < groupIndex + { + groupIndex -= 1 + } + var rootGroupSiblings = allRootGroups.filter { $0.id != group.id } var movedGroup = group movedGroup.parentGroupId = nil - rootGroupSiblings.insert(movedGroup, at: groupIndex) + rootGroupSiblings.insert(movedGroup, at: min(groupIndex, rootGroupSiblings.count)) for (order, var g) in rootGroupSiblings.enumerated() { g.sortOrder = order