AtomGit Flutter鸿蒙客户端:文件树与代码浏览

代码浏览的导航路径

从仓库详情页点击"代码"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 编码。

相关推荐
stringwu37 分钟前
Flutter 开发必备:MVI 架构的高效实现指南
前端·flutter
落魄Android在线炒饭6 小时前
Android 自定义HAL开发篇之 HIDL篇——从入门到实战(上)
android
plainGeekDev6 小时前
广播接收器 → Flow + Lifecycle
android·java·kotlin
plainGeekDev6 小时前
EventBus → SharedFlow
android·java·kotlin
Flynt7 小时前
npm v12 来了:allowScripts 默认关闭,我的项目差点跑不起来
安全·npm·node.js
程序员老刘17 小时前
Flutter版本选择指南:3.44系列继续观望 | 2026年6月
flutter·ai编程·客户端
37手游移动客户端团队1 天前
招聘-高级安卓开发工程师
android·客户端
用户41659673693551 天前
WebView 请求异常排查操作手册
android·前端
Kapaseker1 天前
学不动了,入门 Compose Styles API
android·kotlin
花椒技术2 天前
HJPusher / HJPlayer SDK 实践:我们为什么把直播推播链路拆成一套可复用能力
设计模式·harmonyos·直播