
代码浏览的导航路径
从仓库详情页点击"代码"Tab 进入文件树页面:
RepoDetailScreen → FileTreeScreen → CodeViewScreen
/repo /repo/code /repo/blob
参数传递链:
dart
// 从详情页跳转文件树
Navigator.pushNamed(context, '/repo/code', arguments: {
'owner': owner,
'name': name,
'branch': repository.defaultBranch ?? 'main',
});
// 从文件树跳转代码查看
Navigator.pushNamed(context, '/repo/blob', arguments: {
'owner': owner,
'name': name,
'branch': branch,
'path': node.path,
});
CodeProvider
负责文件树和文件内容的加载,是代码浏览的数据核心:
dart
class CodeProvider extends ChangeNotifier {
final AtomGitApiClient _apiClient;
List<FileNode> _tree = [];
String? _fileContent;
bool _isLoading = false;
String? _error;
Future<void> loadFileTree(String owner, String repo, String branch,
[String path = '']) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final encodedOwner = Uri.encodeComponent(owner);
final encodedRepo = Uri.encodeComponent(repo);
String apiPath;
if (path.isEmpty) {
apiPath = '/repos/$encodedOwner/$encodedRepo/contents';
} else {
apiPath = '/repos/$encodedOwner/$encodedRepo/contents/$path';
}
final response = await _apiClient.get(apiPath,
queryParams: {'ref': branch});
final items = parseList<dynamic>(response.data) ?? [];
_tree = items
.whereType<Map<String, dynamic>>()
.map(FileNode.fromJson)
.toList();
} on ApiException catch (e) {
_error = e.message;
} finally {
_isLoading = false;
notifyListeners();
}
}
}
路径为空时加载根目录内容,非空时加载子目录内容,复用同一个方法。
文件内容加载
dart
Future<void> loadFileContent(
String owner, String repo, String path) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final encodedOwner = Uri.encodeComponent(owner);
final encodedRepo = Uri.encodeComponent(repo);
final segments = path.split('/');
final encodedPath = segments
.map((s) => Uri.encodeComponent(s))
.join('/');
final response = await _apiClient.get(
'/repos/$encodedOwner/$encodedRepo/contents/$encodedPath',
);
final data = parseMap(response.data);
if (data != null && data['content'] is String) {
final content = data['content'] as String;
_fileContent = utf8.decode(base64.decode(content));
}
} on ApiException catch (e) {
_error = e.message;
} finally {
_isLoading = false;
notifyListeners();
}
}
路径中的每个 / 分段单独编码再拼接,保证特殊字符的路径能正确处理。
FileNode 模型
dart
class FileNode {
final String name;
final String path;
final String? sha;
final int? size;
final String type; // 'blob' 或 'tree'
final List<FileNode>? children;
bool get isDirectory => type == 'tree';
}
目录(type='tree')可能有嵌套的 children,文件(type='blob')则是叶子节点。
FileTreeScreen --- 原地目录导航
核心交互:点击目录不是推入新页面,而是在当前页原地刷新内容:
dart
class FileTreeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final args =
ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
final owner = args['owner'] as String;
final name = args['name'] as String;
final branch = args['branch'] as String? ?? 'main';
return ChangeNotifierProvider(
create: (_) =>
CodeProvider(context.read<AtomGitApiClient>())
..loadFileTree(owner, name, branch),
child: _FileTreeBody(owner: owner, name: name, branch: branch),
);
}
}
FileTile 组件根据类型展示不同图标:
dart
Widget _FileTile(BuildContext context, FileNode node,
String owner, String name, String branch) {
final isDir = node.isDirectory;
return ListTile(
leading: Icon(
isDir ? Icons.folder : Icons.insert_drive_file_outlined,
color: isDir ? Theme.of(context).colorScheme.primary : null,
),
title: Text(node.name),
trailing: isDir
? const Icon(Icons.chevron_right)
: Text(_formatSize(node.size ?? 0)),
onTap: () {
final provider = context.read<CodeProvider>();
if (isDir) {
provider.loadFileTree(owner, name, branch, node.path);
} else {
Navigator.pushNamed(context, '/repo/blob', arguments: {
'owner': owner,
'name': name,
'branch': branch,
'path': node.path,
});
}
},
);
}
文件大小格式化
dart
String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
CodeViewScreen --- 带行号的代码展示
双行 AppBar
dart
AppBar(
title: Text(fileName),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(24),
child: Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(fullPath,
style: Theme.of(context).textTheme.bodySmall),
),
),
),
)
标题显示文件名,底部显示完整路径。
带行号渲染
dart
Widget _buildCodeView(String content) {
final lines = content.split('\n');
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(lines.length, (index) {
return _CodeLine(
lineNumber: index + 1,
content: lines[index],
);
}),
),
),
);
}
每行代码由行号和内容组成:
dart
class _CodeLine extends StatelessWidget {
final int lineNumber;
final String content;
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 48,
padding: const EdgeInsets.only(right: 8),
alignment: Alignment.centerRight,
color: isDark ? Colors.grey[900] : Colors.grey[200],
child: Text('$lineNumber',
style: TextStyle(
color: Colors.grey,
fontSize: 12,
fontFamily: 'monospace',
)),
),
const SizedBox(width: 8),
Text(content,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
)),
],
);
}
}
行号固定 48px 宽度右对齐,深色模式下自动切换背景色。代码使用等宽字体渲染。
API 内容接口
两个核心 API:
| 接口 | 用途 |
|---|---|
GET /repos/{owner}/{repo}/contents |
获取根目录文件列表 |
GET /repos/{owner}/{repo}/contents/{path} |
获取子目录或文件内容 |
两个接口都支持 ?ref={branch} 参数指定分支。文件内容的 content 字段为 Base64 编码。