Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apple/TmuxDeck/TmuxDeck.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
D3E4F5A60718B9C1E5F6A707 /* HelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E4F5A60718B9C1E5F6A708 /* HelpView.swift */; };
D3E4F5A60718B9C1E5F6A709 /* TerminalPoolService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E4F5A60718B9C1E5F6A70A /* TerminalPoolService.swift */; };
D3E4F5A60718B9C1E5F6A70B /* SessionOrderStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E4F5A60718B9C1E5F6A70C /* SessionOrderStore.swift */; };
E4F5A60718B9C1E5F6A70D01 /* ModifierToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4F5A60718B9C1E5F6A70D02 /* ModifierToolbar.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand Down Expand Up @@ -105,6 +106,7 @@
D3E4F5A60718B9C1E5F6A708 /* HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpView.swift; sourceTree = "<group>"; };
D3E4F5A60718B9C1E5F6A70A /* TerminalPoolService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPoolService.swift; sourceTree = "<group>"; };
D3E4F5A60718B9C1E5F6A70C /* SessionOrderStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionOrderStore.swift; sourceTree = "<group>"; };
E4F5A60718B9C1E5F6A70D02 /* ModifierToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifierToolbar.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand All @@ -129,6 +131,7 @@
EFBB292A794852C6A179AEAA /* VoiceInputButton.swift */,
B1C2D3E4F5A60718A9B0C1E3 /* PaneIndicator.swift */,
C2D3E4F5A60718B9B0C1E4F6 /* ScrollbackOverlayView.swift */,
E4F5A60718B9C1E5F6A70D02 /* ModifierToolbar.swift */,
);
path = Terminal;
sourceTree = "<group>";
Expand Down Expand Up @@ -388,6 +391,7 @@
D3E4F5A60718B9C1E5F6A707 /* HelpView.swift in Sources */,
D3E4F5A60718B9C1E5F6A709 /* TerminalPoolService.swift in Sources */,
D3E4F5A60718B9C1E5F6A70B /* SessionOrderStore.swift in Sources */,
E4F5A60718B9C1E5F6A70D01 /* ModifierToolbar.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
4 changes: 1 addition & 3 deletions apple/TmuxDeck/TmuxDeck/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@ struct AdaptiveContainerView: View {
),
session: target.session,
isFullscreen: $isFullscreen,
terminalPool: appState.terminalPool
)
.id(target.id)
} else {
Expand Down Expand Up @@ -185,8 +184,7 @@ struct AdaptiveContainerView: View {
preferences: appState.preferences,
container: dest.container,
session: dest.session,
isFullscreen: $isFullscreen,
terminalPool: appState.terminalPool
isFullscreen: $isFullscreen
)
}
}
Expand Down
11 changes: 11 additions & 0 deletions apple/TmuxDeck/TmuxDeck/Models/AuthModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,15 @@ struct OkResponse: Codable {

struct ErrorResponse: Codable {
let detail: String
let remainingAttempts: Int?
let retryAfter: Double?
let locked: Bool?
}

struct OrderRequest: Codable {
let order: [String]
}

struct OrderResponse: Codable {
let order: [String]
}
62 changes: 51 additions & 11 deletions apple/TmuxDeck/TmuxDeck/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,24 @@ final class APIClient {
)
}

// MARK: - Ordering

func getContainerOrder() async throws -> OrderResponse {
try await get("/api/v1/ordering/containers")
}

func saveContainerOrder(_ order: [String]) async throws -> OrderResponse {
try await put("/api/v1/ordering/containers", body: OrderRequest(order: order))
}

func getSessionOrder(containerId: String) async throws -> OrderResponse {
try await get("/api/v1/ordering/containers/\(containerId)/sessions")
}

func saveSessionOrder(containerId: String, order: [String]) async throws -> OrderResponse {
try await put("/api/v1/ordering/containers/\(containerId)/sessions", body: OrderRequest(order: order))
}

// MARK: - Templates

func getTemplates() async throws -> [TemplateResponse] {
Expand Down Expand Up @@ -252,8 +270,8 @@ final class APIClient {
request.httpBody = try encoder.encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let (_, response) = try await urlSession.data(for: request)
try validateResponse(response)
let (data, response) = try await urlSession.data(for: request)
try validateResponse(response, data: data)
}

private func patch<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
Expand All @@ -263,18 +281,25 @@ final class APIClient {
return try await perform(request)
}

private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
var request = try makeRequest(path: path, method: "PUT")
request.httpBody = try encoder.encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return try await perform(request)
}

private func patchNoContent<B: Encodable>(_ path: String, body: B) async throws {
var request = try makeRequest(path: path, method: "PATCH")
request.httpBody = try encoder.encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (_, response) = try await urlSession.data(for: request)
try validateResponse(response)
let (data, response) = try await urlSession.data(for: request)
try validateResponse(response, data: data)
}

private func delete(_ path: String) async throws {
let request = try makeRequest(path: path, method: "DELETE")
let (_, response) = try await urlSession.data(for: request)
try validateResponse(response)
let (data, response) = try await urlSession.data(for: request)
try validateResponse(response, data: data)
}

private func makeRequest(path: String, method: String) throws -> URLRequest {
Expand All @@ -292,7 +317,7 @@ final class APIClient {

private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await urlSession.data(for: request)
try validateResponse(response)
try validateResponse(response, data: data)
do {
return try decoder.decode(T.self, from: data)
} catch {
Expand All @@ -303,16 +328,29 @@ final class APIClient {
/// Called when a 401 is received — set by AppState to redirect to login
var onUnauthorized: (() -> Void)?

private func validateResponse(_ response: URLResponse) throws {
private func validateResponse(_ response: URLResponse, data: Data? = nil) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
return
case 401:
Task { @MainActor in
onUnauthorized?()
case 401, 423, 429:
// Try to parse rate limit info from response body
if let data = data,
let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
if httpResponse.statusCode == 401 && errorResponse.remainingAttempts == nil {
Task { @MainActor in onUnauthorized?() }
}
throw APIError.rateLimited(
message: errorResponse.detail,
remainingAttempts: errorResponse.remainingAttempts,
retryAfter: errorResponse.retryAfter,
locked: errorResponse.locked ?? false
)
}
if httpResponse.statusCode == 401 {
Task { @MainActor in onUnauthorized?() }
}
throw APIError.unauthorized
case 404:
Expand All @@ -335,6 +373,7 @@ enum APIError: LocalizedError {
case serverMessage(String)
case decodingFailed(Error)
case networkError(Error)
case rateLimited(message: String, remainingAttempts: Int?, retryAfter: Double?, locked: Bool)

var errorDescription: String? {
switch self {
Expand All @@ -347,6 +386,7 @@ enum APIError: LocalizedError {
case .serverMessage(let msg): return msg
case .decodingFailed(let error): return "Failed to decode response: \(error.localizedDescription)"
case .networkError(let error): return error.localizedDescription
case .rateLimited(let message, _, _, _): return message
}
}
}
52 changes: 44 additions & 8 deletions apple/TmuxDeck/TmuxDeck/Services/SessionOrderStore.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
import Foundation

struct SessionOrderStore {
private static func key(for containerId: String) -> String {
"tmuxdeck_sessionOrder_\(containerId)"
@Observable
final class OrderingService {
private let apiClient: APIClient
private var containerOrder: [String] = []
private var sessionOrders: [String: [String]] = [:]

init(apiClient: APIClient) {
self.apiClient = apiClient
}

func sorted(sessions: [TmuxSessionResponse], for containerId: String) -> [TmuxSessionResponse] {
let savedOrder = UserDefaults.standard.stringArray(forKey: Self.key(for: containerId)) ?? []
guard !savedOrder.isEmpty else { return sessions }
// MARK: - Container ordering

func sortedContainers(_ containers: [ContainerResponse]) -> [ContainerResponse] {
guard !containerOrder.isEmpty else { return containers }
let orderMap = Dictionary(uniqueKeysWithValues: containerOrder.enumerated().map { ($1, $0) })
return containers.sorted { a, b in
let ai = orderMap[a.id] ?? Int.max
let bi = orderMap[b.id] ?? Int.max
return ai < bi
}
}

func fetchContainerOrder() async {
if let response = try? await apiClient.getContainerOrder() {
containerOrder = response.order
}
}

func saveContainerOrder(_ ids: [String]) async {
containerOrder = ids
_ = try? await apiClient.saveContainerOrder(ids)
}

// MARK: - Session ordering

func sortedSessions(_ sessions: [TmuxSessionResponse], for containerId: String) -> [TmuxSessionResponse] {
let savedOrder = sessionOrders[containerId] ?? []
guard !savedOrder.isEmpty else { return sessions }
let orderMap = Dictionary(uniqueKeysWithValues: savedOrder.enumerated().map { ($1, $0) })
return sessions.sorted { a, b in
let ai = orderMap[a.id] ?? Int.max
Expand All @@ -17,7 +46,14 @@ struct SessionOrderStore {
}
}

func setOrder(sessionIds: [String], for containerId: String) {
UserDefaults.standard.set(sessionIds, forKey: Self.key(for: containerId))
func fetchSessionOrder(for containerId: String) async {
if let response = try? await apiClient.getSessionOrder(containerId: containerId) {
sessionOrders[containerId] = response.order
}
}

func saveSessionOrder(_ ids: [String], for containerId: String) async {
sessionOrders[containerId] = ids
_ = try? await apiClient.saveSessionOrder(containerId: containerId, order: ids)
}
}
4 changes: 2 additions & 2 deletions apple/TmuxDeck/TmuxDeck/Services/TerminalConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ final class TerminalConnection {
sendText("SELECT_WINDOW:\(index)")
}

func scroll(direction: String, count: Int = 3) {
sendText("SCROLL:\(direction):\(count)")
func scroll(direction: String, count: Int = 3, type: String = "line") {
sendText("SCROLL:\(direction):\(count):\(type)")
}

func selectPane(direction: String) {
Expand Down
1 change: 0 additions & 1 deletion apple/TmuxDeck/TmuxDeck/ViewModels/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ final class AppState {
let apiClient = APIClient()
let notificationService = NotificationService()
let preferences = UserPreferences()
let terminalPool = TerminalPoolService()

var servers: [ServerConfig] = ServerConfig.loadAll() {
didSet {
Expand Down
12 changes: 9 additions & 3 deletions apple/TmuxDeck/TmuxDeck/ViewModels/ContainerListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ final class ContainerListViewModel {
var searchText = ""

private let apiClient: APIClient
let orderingService: OrderingService

init(apiClient: APIClient) {
self.apiClient = apiClient
self.orderingService = OrderingService(apiClient: apiClient)
}

var filteredContainers: [ContainerResponse] {
let ordered = orderingService.sortedContainers(containers)
if searchText.isEmpty {
return containers
return ordered
}
return containers.filter {
return ordered.filter {
$0.displayName.localizedCaseInsensitiveContains(searchText) ||
$0.name.localizedCaseInsensitiveContains(searchText)
}
Expand All @@ -33,7 +36,10 @@ final class ContainerListViewModel {
error = nil

do {
let response = try await apiClient.getContainers()
async let containersFetch = apiClient.getContainers()
async let orderFetch: () = orderingService.fetchContainerOrder()
let response = try await containersFetch
_ = await orderFetch
containers = response.containers
dockerError = response.dockerError
} catch {
Expand Down
Loading