f1dc2e1a8d
- AppModel: AuthPhase 상태기계(checking/loggedOut/ready) + live() 팩토리 (LiveDSClient + realRouter, ask 토큰 = TokenProvider 단일 소스) + bootstrap (refresh 쿠키 무로그인 복귀, single-shot, 취소 시 재시도 복원) + login(TOTP 개행·공백 정규화) + 사용 중 세션 만료 시 loggedOut 강등 + 401 회전 후 다운로드 ?token= 사본 재동기화(guarded 깔때기) - LoginView 신규(기능 셸, 서버 host 표시, 서버 detail 메시지 노출) - RootView: 인증 게이트 + errorText 하단 배너(no-silent-fallback 가시화) - DSApp: 기본 .live(publicTLS=document.hyungi.net/api), DSAPP_FIXTURE=1 / DSAPP_DS_URL env 스위치(파싱 실패 = fail-loud, prod silent fallback 금지) - LiveDSClient.currentAccessToken() — realRouter ask 토큰 closure 용 - AppFeatureTests 신규 10건(인증 상태기계·single-shot·transport 사유·totp) 검증: swift test 82/82 green + xcodebuild .app BUILD SUCCEEDED + 라이브 negative-path(/auth/login 401·/auth/refresh 401, 본 서버 양 경로 도달). 3-렌즈 어드버서리얼 리뷰 반영(재진입 가드/transport 구분/env fail-loud/토큰 사본 동기화/만료 강등). Sources/AI 무수정(시그니처 동결 준수). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
4.9 KiB
Swift
160 lines
4.9 KiB
Swift
import SwiftUI
|
|
import DSKit
|
|
|
|
/// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment
|
|
/// (no shell-level auto-inherit). macOS-only target.
|
|
/// 인증 게이트: checking(부팅 시 refresh 쿠키 복귀 시도) → loggedOut(LoginView) → ready(3-pane 셸).
|
|
public struct RootView: View {
|
|
@Environment(AppModel.self) private var model
|
|
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
|
|
|
public init() {}
|
|
|
|
public var body: some View {
|
|
Group {
|
|
switch model.authPhase {
|
|
case .checking:
|
|
ProgressView("서버 연결 확인 중")
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Sage.surface)
|
|
case .loggedOut:
|
|
LoginView()
|
|
case .ready:
|
|
shell
|
|
}
|
|
}
|
|
.task { await model.bootstrap() }
|
|
}
|
|
|
|
private var shell: some View {
|
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
|
Sidebar()
|
|
.navigationSplitViewColumnWidth(min: 220, ideal: 250)
|
|
} content: {
|
|
ContentColumn()
|
|
.navigationSplitViewColumnWidth(min: 300, ideal: 380)
|
|
} detail: {
|
|
DetailColumn()
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
.tint(Sage.brand)
|
|
.safeAreaInset(edge: .bottom) {
|
|
// 라이브 데이터 호출 실패 가시화 (no-silent-fallback) — 닫기 전까지 유지.
|
|
if let err = model.errorText {
|
|
HStack(spacing: 10) {
|
|
Text(err)
|
|
.font(.callout)
|
|
.foregroundStyle(.white)
|
|
.lineLimit(2)
|
|
Spacer()
|
|
Button("닫기") { model.errorText = nil }
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.background(Sage.danger)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Sidebar: View {
|
|
@Environment(AppModel.self) private var model
|
|
|
|
var body: some View {
|
|
let selection = Binding<AppModel.Section?>(
|
|
get: { model.section },
|
|
set: { if let v = $0 { model.section = v } }
|
|
)
|
|
List(selection: selection) {
|
|
Section {
|
|
ForEach(AppModel.Section.allCases) { s in
|
|
Text(s.title).tag(s)
|
|
}
|
|
}
|
|
if model.section == .documents, !model.tree.isEmpty {
|
|
Section("도메인") {
|
|
ForEach(model.tree) { node in
|
|
DomainRow(node: node)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.sidebar)
|
|
.background(Sage.sidebar)
|
|
}
|
|
}
|
|
|
|
struct DomainRow: View {
|
|
@Environment(AppModel.self) private var model
|
|
let node: DomainTreeNode
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Circle().fill(Sage.domainColor(node.name)).frame(width: 8, height: 8)
|
|
Text(node.name).font(.callout).foregroundStyle(Sage.ink)
|
|
Spacer()
|
|
Text("\(node.count)").font(.caption).foregroundStyle(Sage.muted)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { model.section = .documents }
|
|
}
|
|
}
|
|
|
|
struct ContentColumn: View {
|
|
@Environment(AppModel.self) private var model
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch model.section {
|
|
case .dashboard: DashboardView()
|
|
case .documents: DocumentListView()
|
|
case .search: SearchView()
|
|
case .ask: AskView()
|
|
case .memos: MemoListView()
|
|
case .digest: DigestView()
|
|
}
|
|
}
|
|
.navigationTitle(model.section.title)
|
|
}
|
|
}
|
|
|
|
struct DetailColumn: View {
|
|
@Environment(AppModel.self) private var model
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch model.section {
|
|
case .documents:
|
|
if let d = model.documentDetail { DocumentDetailView(detail: d) }
|
|
else { EmptyState(text: "문서를 선택하세요") }
|
|
case .memos:
|
|
if let m = model.memoDetail { MemoDetailView(memo: m) }
|
|
else { EmptyState(text: "메모를 선택하세요") }
|
|
default:
|
|
EmptyState(text: model.section.title)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct EmptyState: View {
|
|
let text: String
|
|
var body: some View {
|
|
Text(text)
|
|
.foregroundStyle(Sage.muted)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Sage.surface)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
#Preview("DS App — full shell") {
|
|
@Previewable @State var model = AppModel.preview
|
|
RootView()
|
|
.environment(model)
|
|
.frame(minWidth: 1000, minHeight: 660)
|
|
}
|
|
#endif
|