Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"framer-motion": "^12.23.26",
"front-matter": "^4.0.2",
"github-slugger": "^2.0.0",
"lodash": "^4.17.21",
"lodash": "^4.17.23",
"next": "^15.5.9",
"nprogress": "0.2.0",
"path-browserify": "^1.0.1",
Expand Down
9 changes: 8 additions & 1 deletion docs/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7043,7 +7043,7 @@ __metadata:
http-server: "npm:^14.1.1"
jest: "npm:^29.7.0"
jest-environment-jsdom: "npm:^29.7.0"
lodash: "npm:^4.17.21"
lodash: "npm:^4.17.23"
next: "npm:^15.5.9"
next-router-mock: "npm:^0.9.13"
nprogress: "npm:0.2.0"
Expand Down Expand Up @@ -9430,6 +9430,13 @@ __metadata:
languageName: node
linkType: hard

"lodash@npm:^4.17.23":
version: 4.17.23
resolution: "lodash@npm:4.17.23"
checksum: 10c0/1264a90469f5bb95d4739c43eb6277d15b6d9e186df4ac68c3620443160fc669e2f14c11e7d8b2ccf078b81d06147c01a8ccced9aab9f9f63d50dcf8cace6bf6
languageName: node
linkType: hard

"longest-streak@npm:^3.0.0":
version: 3.1.0
resolution: "longest-streak@npm:3.1.0"
Expand Down
2 changes: 2 additions & 0 deletions packages/expo-dev-client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

### 🎉 New features

- Add sources button to dev client on iOS. ([#42493](https://github.com/expo/expo/pull/42493) by [@EvanBacon](https://github.com/EvanBacon))

### 🐛 Bug fixes

### 💡 Others
Expand Down
2 changes: 2 additions & 0 deletions packages/expo-dev-menu/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

### 🎉 New features

- Add "Source Code Explorer" screen to iOS dev menu ([#42493](https://github.com/expo/expo/pull/42493) by [@EvanBacon](https://github.com/EvanBacon))

### 🐛 Bug fixes

### 💡 Others
Expand Down
23 changes: 23 additions & 0 deletions packages/expo-dev-menu/ios/SwiftUI/DevMenuDeveloperTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,29 @@ struct DevMenuDeveloperTools: View {
action: viewModel.toggleFastRefresh,
disabled: !(viewModel.devSettings?.isHotLoadingAvailable ?? true)
)

Divider()

NavigationLink(destination: SourceMapExplorerView()) {
HStack {
Image(systemName: "map")
.frame(width: 24, height: 24)
.foregroundColor(.primary)
.opacity(0.6)

Text("Source code explorer")
.foregroundColor(.primary)

Spacer()

Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(Color.expoSecondarySystemBackground)
}
.buttonStyle(.plain)
}
.background(Color.expoSystemBackground)
.cornerRadius(18)
Expand Down
2 changes: 2 additions & 0 deletions packages/expo-dev-menu/ios/SwiftUI/DevMenuMainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,7 @@ struct DevMenuMainView: View {
.padding()
}
.environmentObject(viewModel)
.navigationTitle("Dev Menu")
.navigationBarHidden(true)
}
}
275 changes: 275 additions & 0 deletions packages/expo-dev-menu/ios/SwiftUI/SourceMapExplorerView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
// Copyright 2015-present 650 Industries. All rights reserved.

import SwiftUI
import ExpoModulesCore

struct SourceMapExplorerView: View {
@StateObject private var viewModel = SourceMapExplorerViewModel()

private var isSearching: Bool {
!viewModel.searchText.isEmpty
}

var body: some View {
Group {
switch viewModel.loadingState {
case .idle, .loading:
loadingView
case .loaded:
FolderListView(
title: "Source Code Explorer",
nodes: viewModel.filteredFileTree,
sourceMap: viewModel.sourceMap,
stats: viewModel.sourceMapStats,
isSearching: isSearching
)
case .error(let error):
errorView(error)
}
}
.navigationTitle("Source Code Explorer")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchText, placement: .automatic, prompt: "Search files")
.task {
await viewModel.loadSourceMap()
}
}

private var loadingView: some View {
VStack(spacing: 12) {
ProgressView()
Text("Loading source map...")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func errorView(_ error: SourceMapError) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 40))
.foregroundColor(.orange)

Text("Failed to load source map")
.font(.headline)

Text(error.errorDescription ?? "Unknown error")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)

Button("Retry") {
Task { await viewModel.loadSourceMap() }
}
.buttonStyle(.borderedProminent)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

struct FolderListView: View {
let title: String
let nodes: [FileTreeNode]
let sourceMap: SourceMap?
let stats: (files: Int, totalSize: String)?
let isSearching: Bool

var body: some View {
List {
if nodes.isEmpty {
Text(isSearching ? "No files found" : "Empty folder")
.foregroundColor(.secondary)
} else {
ForEach(nodes) { node in
if node.isDirectory {
NavigationLink(destination: FolderListView(
title: node.name,
nodes: node.children,
sourceMap: sourceMap,
stats: nil,
isSearching: false
)) {
FileRow(node: node, showPath: isSearching)
}
} else {
NavigationLink(destination: CodeFileView(node: node, sourceMap: sourceMap)) {
FileRow(node: node, showPath: isSearching)
}
}
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(isSearching ? "Search Results" : title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if let stats = stats {
Menu {
Label("\(stats.files) files", systemImage: "doc.on.doc")
Label(stats.totalSize, systemImage: "internaldrive")
} label: {
Image(systemName: "info.circle")
}
}
}
}
}
}

struct FileRow: View {
let node: FileTreeNode
var showPath: Bool = false

private var parentDirectory: String? {
let path = node.path
guard let lastSlash = path.lastIndex(of: "/") else { return nil }
let parent = String(path[..<lastSlash])
return parent.isEmpty ? nil : parent
}

var body: some View {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(node.name)
.lineLimit(1)
if showPath, let parent = parentDirectory {
Text(parent)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
} icon: {
Image(systemName: node.isDirectory ? "folder.fill" : fileIcon)
.foregroundColor(node.isDirectory ? .blue : iconColor)
}
}

private var fileIcon: String {
let ext = (node.name as NSString).pathExtension.lowercased()
switch ext {
case "ts", "tsx", "js", "jsx": return "curlybraces"
case "json": return "doc.text"
case "css", "scss", "sass": return "paintbrush"
case "png", "jpg", "jpeg", "gif", "svg": return "photo"
case "md", "txt": return "doc.plaintext"
default: return "doc"
}
}

private var iconColor: Color {
let ext = (node.name as NSString).pathExtension.lowercased()
switch ext {
case "ts", "tsx": return .blue
case "js", "jsx": return .yellow
case "json": return .orange
case "css", "scss", "sass": return .pink
default: return .secondary
}
}
}

struct CodeFileView: View {
let node: FileTreeNode
let sourceMap: SourceMap?
@Environment(\.colorScheme) private var colorScheme
@State private var highlightedLines: [AttributedString]?

private var content: String {
guard let contentIndex = node.contentIndex,
let sourceMap,
let sourcesContent = sourceMap.sourcesContent,
contentIndex < sourcesContent.count,
let code = sourcesContent[contentIndex] else {
return "// Content not available"
}
return code
}

private var lines: [String] {
content.components(separatedBy: "\n")
}

private var theme: SyntaxHighlighter.Theme {
colorScheme == .dark ? .dark : .light
}

private var lineNumberWidth: CGFloat {
let digits = String(lines.count).count
return CGFloat(digits * 10 + 16)
}

var body: some View {
GeometryReader { geometry in
ScrollView(.vertical) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
LineNumbersColumn(lines: lines, theme: theme, lineNumberWidth: lineNumberWidth)
CodeColumn(lines: lines, highlightedLines: highlightedLines, theme: theme)
}
.frame(minWidth: geometry.size.width, alignment: .leading)
}
}
}
.background(theme.background)
.navigationTitle(node.name)
.navigationBarTitleDisplayMode(.inline)
.task(id: colorScheme) {
highlightedLines = await SyntaxHighlighter.highlightLines(lines, theme: theme)
}
}
}

struct LineNumbersColumn: View {
let lines: [String]
let theme: SyntaxHighlighter.Theme
let lineNumberWidth: CGFloat

var body: some View {
LazyVStack(alignment: .trailing, spacing: 0) {
ForEach(0..<lines.count, id: \.self) { index in
Text("\(index + 1)")
.font(.system(size: 13, weight: .regular, design: .monospaced))
.foregroundColor(theme.lineNumber)
.frame(height: 20)
}
}
.frame(width: lineNumberWidth)
.padding(.vertical, 12)
.background(theme.background.opacity(0.8))
}
}

struct CodeColumn: View {
let lines: [String]
let highlightedLines: [AttributedString]?
let theme: SyntaxHighlighter.Theme

var body: some View {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(0..<lines.count, id: \.self) { index in
if let highlightedLines, index < highlightedLines.count {
Text(highlightedLines[index])
.font(.system(size: 13, weight: .regular, design: .monospaced))
.frame(height: 20, alignment: .leading)
} else {
Text(lines[index].isEmpty ? " " : lines[index])
.font(.system(size: 13, weight: .regular, design: .monospaced))
.foregroundColor(theme.plain)
.frame(height: 20, alignment: .leading)
}
}
}
.padding(.vertical, 12)
.padding(.trailing, 16)
}
}

#Preview {
NavigationView {
SourceMapExplorerView()
}
}
Loading
Loading