Skip to content
Closed
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
6 changes: 5 additions & 1 deletion ora/Models/Folder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@ class Folder: ObservableObject, Identifiable {
var id: UUID
var name: String
var isOpened: Bool
var order: Int

@Relationship(deleteRule: .nullify) var tabs: [Tab] = []
@Relationship(inverse: \TabContainer.folders) var container: TabContainer
init(
id: UUID = UUID(),
name: String,
isOpened: Bool = false,
order: Int = 0,
container: TabContainer
) {
self.id = UUID()
self.id = id
self.name = name
self.isOpened = isOpened
self.order = order
self.container = container
}
}
1 change: 1 addition & 0 deletions ora/Models/Tab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class Tab: ObservableObject, Identifiable {
@Transient @Published var hoveredLinkURL: String?

@Relationship(inverse: \TabContainer.tabs) var container: TabContainer
@Relationship(inverse: \Folder.tabs) var folder: Folder?

init(
id: UUID = UUID(),
Expand Down
48 changes: 47 additions & 1 deletion ora/Modules/Sidebar/ContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ struct ContainerView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var tabManager: TabManager
@State private var draggedItem: UUID?
@State private var showCreateFolderDialog = false
@State private var newFolderName = ""

var body: some View {
VStack(alignment: .leading, spacing: 16) {
Expand Down Expand Up @@ -36,8 +38,24 @@ struct ContainerView: View {
containers: containers
)
Divider()

// Display folders
ForEach(folders) { folder in
FolderView(
folder: folder,
draggedItem: $draggedItem,
onDrag: dragTab,
onSelect: selectTab,
onPinToggle: togglePin,
onFavoriteToggle: toggleFavorite,
onClose: removeTab,
onMoveToContainer: moveTab,
availableContainers: containers
)
}

NormalTabsList(
tabs: normalTabs,
tabs: normalTabsNotInFolders,
draggedItem: $draggedItem,
onDrag: dragTab,
onSelect: selectTab,
Expand All @@ -51,6 +69,23 @@ struct ContainerView: View {
}
}
.modifier(WindowDragIfAvailable())
.contextMenu {
Button("Create New Folder") {
showCreateFolderDialog = true
}
}
.sheet(isPresented: $showCreateFolderDialog) {
CreateFolderSheet(
isPresented: $showCreateFolderDialog,
folderName: $newFolderName,
onCreate: {
if !newFolderName.isEmpty {
_ = tabManager.createFolder(name: newFolderName, in: container)
newFolderName = ""
}
}
)
}
}

private var favoriteTabs: [Tab] {
Expand All @@ -70,6 +105,17 @@ struct ContainerView: View {
.sorted(by: { $0.order > $1.order })
.filter { $0.type == .normal }
}

private var normalTabsNotInFolders: [Tab] {
return container.tabs
.sorted(by: { $0.order > $1.order })
.filter { $0.type == .normal && $0.folder == nil }
}

private var folders: [Folder] {
return container.folders
.sorted(by: { $0.order < $1.order })
}

private func addNewTab() {
appState.showLauncher = true
Expand Down
52 changes: 52 additions & 0 deletions ora/Modules/Sidebar/CreateFolderSheet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import SwiftUI

struct CreateFolderSheet: View {
@Binding var isPresented: Bool
@Binding var folderName: String
let onCreate: () -> Void

@Environment(\.theme) private var theme
@FocusState private var isFocused: Bool

var body: some View {
VStack(spacing: 20) {
Text("Create New Folder")
.font(.headline)
.foregroundColor(theme.foreground)

TextField("Folder name", text: $folderName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.focused($isFocused)
.onSubmit {
if !folderName.isEmpty {
onCreate()
isPresented = false
}
}

HStack(spacing: 12) {
Button("Cancel") {
folderName = ""
isPresented = false
}
.keyboardShortcut(.escape)

Button("Create") {
if !folderName.isEmpty {
onCreate()
isPresented = false
}
}
.keyboardShortcut(.return)
.disabled(folderName.isEmpty)
}
}
.padding()
.frame(width: 300)
.background(theme.solidWindowBackgroundColor)
.onAppear {
folderName = "New Folder"
isFocused = true
}
}
}
197 changes: 197 additions & 0 deletions ora/Modules/Sidebar/FolderView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import SwiftUI
import SwiftData

struct FolderView: View {
let folder: Folder
@Binding var draggedItem: UUID?
let onDrag: (UUID) -> NSItemProvider
let onSelect: (Tab) -> Void
let onPinToggle: (Tab) -> Void
let onFavoriteToggle: (Tab) -> Void
let onClose: (Tab) -> Void
let onMoveToContainer: (Tab, TabContainer) -> Void
let availableContainers: [TabContainer]

@EnvironmentObject var tabManager: TabManager
@Environment(\.theme) private var theme
@State private var isHovering = false
@State private var isEditingName = false
@State private var editedName = ""

var body: some View {
VStack(alignment: .leading, spacing: 4) {
// Folder header
HStack {
Image(systemName: folder.isOpened ? "chevron.down" : "chevron.right")
.font(.system(size: 10))
.foregroundColor(theme.foreground.opacity(0.6))
.onTapGesture {
tabManager.toggleFolderOpen(folder)
}

Image(systemName: "folder.fill")
.font(.system(size: 12))
.foregroundColor(theme.foreground.opacity(0.8))

if isEditingName {
TextField("Folder name", text: $editedName, onCommit: {
if !editedName.isEmpty {
tabManager.renameFolder(folder, newName: editedName)
}
isEditingName = false
})
.textFieldStyle(PlainTextFieldStyle())
.font(.system(size: 13))
.foregroundColor(theme.foreground)
.onAppear {
editedName = folder.name
}
} else {
Text(folder.name)
.font(.system(size: 13))
.foregroundColor(theme.foreground)
.lineLimit(1)
.onTapGesture(count: 2) {
isEditingName = true
}
}

Spacer()

if folder.tabs.count > 0 {
Text("\(folder.tabs.count)")
.font(.system(size: 10))
.foregroundColor(theme.foreground.opacity(0.5))
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(theme.foreground.opacity(0.1))
.cornerRadius(4)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(isHovering ? theme.activeTabBackground.opacity(0.2) : Color.clear)
.cornerRadius(6)
.onHover { isHovering = $0 }
.contextMenu {
Button("Rename Folder") {
isEditingName = true
}

if folder.tabs.isEmpty {
Button("Delete Folder") {
tabManager.deleteFolder(folder)
}
} else {
Button("Delete Folder and Move Tabs Out") {
tabManager.deleteFolder(folder)
}
}
}

// Folder contents (tabs)
if folder.isOpened {
VStack(alignment: .leading, spacing: 2) {
ForEach(folder.tabs.sorted(by: { $0.order > $1.order })) { tab in
TabItem(
tab: tab,
isSelected: tabManager.isActive(tab),
isDragging: draggedItem == tab.id,
onTap: { onSelect(tab) },
onPinToggle: { onPinToggle(tab) },
onFavoriteToggle: { onFavoriteToggle(tab) },
onClose: { onClose(tab) },
onMoveToContainer: { onMoveToContainer(tab, $0) },
availableContainers: availableContainers
)
.padding(.leading, 20)
.onDrag { onDrag(tab.id) }
.onDrop(
of: [.text],
delegate: FolderTabDropDelegate(
folder: folder,
targetTab: tab,
draggedItem: $draggedItem,
tabManager: tabManager
)
)
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: folder.tabs.map(\.id))
}
}
.onDrop(
of: [.text],
delegate: FolderDropDelegate(
folder: folder,
draggedItem: $draggedItem,
tabManager: tabManager
)
)
}
}

// MARK: - Drop Delegates

struct FolderDropDelegate: DropDelegate {
let folder: Folder
@Binding var draggedItem: UUID?
let tabManager: TabManager

func validateDrop(info: DropInfo) -> Bool {
return info.hasItemsConforming(to: [.text])
}

func dropEntered(info: DropInfo) {
// Visual feedback when hovering
}

func performDrop(info: DropInfo) -> Bool {
guard let draggedTabId = draggedItem,
let tab = folder.container.tabs.first(where: { $0.id == draggedTabId }) else {
return false
}

withAnimation {
tabManager.moveTabToFolder(tab, folder: folder)
}

return true
}
}

struct FolderTabDropDelegate: DropDelegate {
let folder: Folder
let targetTab: Tab
@Binding var draggedItem: UUID?
let tabManager: TabManager

func validateDrop(info: DropInfo) -> Bool {
return info.hasItemsConforming(to: [.text])
}

func performDrop(info: DropInfo) -> Bool {
guard let draggedTabId = draggedItem,
let draggedTab = folder.container.tabs.first(where: { $0.id == draggedTabId }),
draggedTab.id != targetTab.id else {
return false
}

withAnimation {
// Move tab to folder if not already in it
if draggedTab.folder != folder {
tabManager.moveTabToFolder(draggedTab, folder: folder)
}

// Reorder within folder
let draggedOrder = draggedTab.order
let targetOrder = targetTab.order

if draggedOrder != targetOrder {
folder.container.reorderTabs(from: draggedTab, to: targetTab)
}
}

return true
}
}
Loading