一、阶段目标

1.1 任务来源

在开源社区平台中,代码仓库的详情页面是开发者了解项目结构、浏览源代码的核心入口。本次开发任务的核心目标是构建一个功能完整、体验优秀的代码仓库详情页,特别是要实现能够直观展示项目结构的可视化目录树功能。

项目现状分析:

  • 现有仓库列表页面已基本完成
  • 用户能够浏览和搜索开源项目
  • 缺少进入仓库后的详细内容展示页面
  • 需要完整的文件导航和内容预览能力

这次核心任务是实现代码仓库的详情页

  1. 核心任务:实现代码仓详情页

    • 页面分为上下两大核心区域
    • 必须自主处理数据获取、解析和渲染,避免网页套壳
  2. 支线任务1:可视化目录树(上部区域)✅ 本次完成

    • 完整展示仓库的多层级文件目录结构
    • 需要递归渲染或使用树形控件展示嵌套文件夹关系
  3. 支线任务2:渲染README文档(下部主区域)- 待实现

  4. 支线任务3:实现层级导航与内容展示 - 待实现

  5. 拓展任务:代码高亮 - 待实现

1.2 本次实现成果

  • ✅ 创建 TreeNode 数据模型,支持树形结构构建
  • ✅ 创建 DirectoryTree 可视化组件
  • ✅ 重构仓库详情页,实现上下两区域布局
  • ✅ 修复私有仓库访问问题(使用 namespacePath

二、核心实现详解

2.1 Repository 模型优化

关键修复:使用 path 而非 name 字段

AtomGit API 返回的仓库数据中:

  • path:仓库的实际路径名(如 HarmonyosNext
  • name:显示名称
// lib/models/repository.dart

factory Repository.fromJson(Map<String, dynamic> json) {
  final namespace = json['namespace'];
  String? namespacePath;
  
  // 从 namespace 获取正确的 owner 路径
  if (namespace is Map<String, dynamic>) {
    namespacePath = namespace['path']?.toString();
  }

  // 使用 path 字段作为仓库名(API 调用需要),而不是 name(可能是中文)
  final name = json['path']?.toString() ?? json['name']?.toString() ?? '';
  
  return Repository(
    // ...
    name: name,
    namespacePath: namespacePath,  // 保存 namespace.path 用于 API 调用
  );
}

2.2 导航时使用正确的 owner

// lib/main.dart

void _navigateToRepositoryDetail(Repository repo) {
  String owner;
  
  // 优先使用 namespacePath(这是 API 调用需要的正确路径)
  if (repo.namespacePath != null && repo.namespacePath!.isNotEmpty) {
    owner = repo.namespacePath!;
  }
  // 从 fullName 中提取 owner(fallback)
  else if (repo.fullName.isNotEmpty && repo.fullName.contains('/')) {
    final lastSlashIndex = repo.fullName.lastIndexOf('/');
    owner = repo.fullName.substring(0, lastSlashIndex);
  } else {
    owner = repo.ownerName ?? '';
  }

  Navigator.of(context).push(
    MaterialPageRoute(
      builder: (context) => RepositoryDetailPage(
        owner: owner,          // 使用正确的 namespace path
        repo: repo.name,       // 使用 path 而非显示名
        repoId: repo.id,
      ),
    ),
  );
}

2.3 智能获取目录树

// lib/services/api_service.dart

/// 智能获取目录树:优先使用 file_list API(返回完整文件列表)
static Future<List<TreeNode>> getRepositoryTreeSmart({
  required String owner,
  required String repo,
  int? repoId,
  String sha = 'master',
  bool recursive = true,
}) async {
  // 1. 首先尝试 file_list API(返回完整的文件列表,最可靠)
  try {
    final paths = await getFileList(owner, repo, refName: sha);
    if (paths.isNotEmpty) {
      return buildTreeNodesFromPaths(paths);
    }
  } catch (e) {
    print('[DEBUG] file_list API failed: $e');
  }
  
  // 2. file_list 失败,尝试 tree API(可能有数量限制)
  try {
    final result = await getRepositoryTree(owner, repo, sha: sha);
    return result;
  } catch (e) {
    print('[DEBUG] tree API failed: $e');
  }
  
  // 3. tree API 也失败,尝试 contents API(递归获取,较慢)
  try {
    final result = await getRepositoryContentsRecursive(owner, repo, ref: sha);
    return result;
  } catch (e) {
    throw Exception('所有 API 都失败了,无法获取目录树');
  }
}

2.4 从文件路径构建目录树

file_list API 只返回文件路径,需要手动构建目录节点:

/// 从文件路径列表构建 TreeNode 列表(包含目录和文件)
static List<TreeNode> buildTreeNodesFromPaths(List<String> paths) {
  final nodes = <TreeNode>[];
  final addedDirs = <String>{};
  
  for (final path in paths) {
    // 添加所有父目录
    final parts = path.split('/');
    String dirPath = '';
    for (int i = 0; i < parts.length - 1; i++) {
      dirPath = dirPath.isEmpty ? parts[i] : '$dirPath/${parts[i]}';
      if (!addedDirs.contains(dirPath)) {
        addedDirs.add(dirPath);
        nodes.add(TreeNode(
          sha: '',
          path: dirPath,
          name: parts[i],
          type: 'tree', // 目录
        ));
      }
    }
    
    // 添加文件
    final name = path.contains('/') 
        ? path.substring(path.lastIndexOf('/') + 1)
        : path;
    nodes.add(TreeNode(
      sha: '',
      path: path,
      name: name,
      type: 'blob', // 文件
    ));
  }
  
  return nodes;
}

2.5 TreeNode 数据模型

// lib/models/tree_node.dart

class TreeNode {
  final String sha;
  final String path;      // 完整相对路径
  final String name;      // 文件/文件夹名称
  final String type;      // "tree" 或 "blob"
  
  List<TreeNode> children; // 子节点列表
  bool isExpanded;         // UI 展开状态
  
  bool get isDirectory => type == 'tree';
  bool get isFile => type == 'blob';
  
  /// 获取文件扩展名
  String? get extension {
    if (!isFile) return null;
    final dotIndex = name.lastIndexOf('.');
    if (dotIndex == -1) return null;
    return name.substring(dotIndex + 1).toLowerCase();
  }
}

2.6 扁平列表转树形结构

/// 从扁平列表构建树形结构
static List<TreeNode> buildTree(List<TreeNode> flatNodes) {
  if (flatNodes.isEmpty) return [];
  
  final Map<String, TreeNode> nodeMap = {};
  final List<TreeNode> rootNodes = [];
  
  // 按路径排序,确保父节点在子节点之前
  final sortedNodes = List<TreeNode>.from(flatNodes)
    ..sort((a, b) => a.path.compareTo(b.path));
  
  for (final node in sortedNodes) {
    nodeMap[node.path] = node;
    
    final lastSlash = node.path.lastIndexOf('/');
    if (lastSlash == -1) {
      rootNodes.add(node);  // 根节点
    } else {
      final parentPath = node.path.substring(0, lastSlash);
      final parent = nodeMap[parentPath];
      if (parent != null) {
        parent.children.add(node);
      } else {
        rootNodes.add(node);
      }
    }
  }
  
  // 排序:文件夹优先,同类型按名称排序
  void sortChildren(List<TreeNode> nodes) {
    nodes.sort((a, b) {
      if (a.isDirectory && !b.isDirectory) return -1;
      if (!a.isDirectory && b.isDirectory) return 1;
      return a.name.toLowerCase().compareTo(b.name.toLowerCase());
    });
    for (final node in nodes) {
      sortChildren(node.children);
    }
  }
  
  sortChildren(rootNodes);
  return rootNodes;
}

2.7 可视化目录树组件

// lib/widgets/directory_tree.dart

class DirectoryTree extends StatefulWidget {
  const DirectoryTree({
    required this.nodes,
    this.onFileSelected,
    this.onFolderSelected,
    this.currentPath,
  });

  final List<TreeNode> nodes;
  final void Function(TreeNode)? onFileSelected;
  final void Function(TreeNode)? onFolderSelected;
  final String? currentPath;
}

class _DirectoryTreeState extends State<DirectoryTree> {
  final Set<String> _expandedPaths = {};
  
  void _initExpandedState() {
    // 默认不展开任何文件夹,用户需要时自己点击展开
    _expandedPaths.clear();
    
    // 如果有 currentPath,展开到该路径
    if (widget.currentPath != null) {
      final parts = widget.currentPath!.split('/');
      String path = '';
      for (int i = 0; i < parts.length - 1; i++) {
        path = path.isEmpty ? parts[i] : '$path/${parts[i]}';
        _expandedPaths.add(path);
      }
    }
  }

  Widget _buildTreeNode(TreeNode node, int depth) {
    final isExpanded = _expandedPaths.contains(node.path);
    
    return Column(
      children: [
        _TreeNodeTile(
          node: node,
          depth: depth,
          isExpanded: isExpanded,
          onTap: () => _handleNodeTap(node),
        ),
        if (node.isDirectory && isExpanded)
          ...node.children.map((child) => _buildTreeNode(child, depth + 1)),
      ],
    );
  }
}

三、用户体验设计

  1. 默认折叠:文件夹默认不展开,用户按需点击
  2. 智能图标:根据文件类型显示不同图标和颜色

四、代码文件变更

4.1 新增文件

文件路径 说明
lib/models/tree_node.dart 目录树节点数据模型
lib/widgets/directory_tree.dart 可视化目录树组件

五、测试结果

在这里插入图片描述
在这里插入图片描述


支线任务1完成! 可视化的目录已成功实现,支持显示完整的仓库文件结构。这次收获还是挺多的,详情页也多了许多内容,优化了应用的用户体验。明天继续干!

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐