source: josm/trunk/src/org/openstreetmap/josm/gui/layer/GpxLayer.java

Last change on this file was 19103, checked in by taylor.smock, 13 months ago

Cleanup some new PMD warnings from PMD 7.x (followup of r19101)

  • Property svn:eol-style set to native
File size: 23.1 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.layer;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5import static org.openstreetmap.josm.tools.I18n.trn;
6
7import java.awt.Color;
8import java.awt.Dimension;
9import java.awt.Graphics2D;
10import java.awt.event.ActionEvent;
11import java.io.File;
12import java.time.Instant;
13import java.util.ArrayList;
14import java.util.Arrays;
15import java.util.Collections;
16import java.util.List;
17import java.util.NoSuchElementException;
18import java.util.stream.Collectors;
19
20import javax.swing.AbstractAction;
21import javax.swing.Action;
22import javax.swing.Icon;
23import javax.swing.JScrollPane;
24import javax.swing.SwingUtilities;
25
26import org.openstreetmap.josm.actions.AutoScaleAction;
27import org.openstreetmap.josm.actions.ExpertToggleAction;
28import org.openstreetmap.josm.actions.ExpertToggleAction.ExpertModeChangeListener;
29import org.openstreetmap.josm.actions.RenameLayerAction;
30import org.openstreetmap.josm.actions.SaveActionBase;
31import org.openstreetmap.josm.data.Bounds;
32import org.openstreetmap.josm.data.Data;
33import org.openstreetmap.josm.data.SystemOfMeasurement;
34import org.openstreetmap.josm.data.gpx.GpxConstants;
35import org.openstreetmap.josm.data.gpx.GpxData;
36import org.openstreetmap.josm.data.gpx.GpxData.GpxDataChangeEvent;
37import org.openstreetmap.josm.data.gpx.GpxData.GpxDataChangeListener;
38import org.openstreetmap.josm.data.gpx.GpxDataContainer;
39import org.openstreetmap.josm.data.gpx.IGpxTrack;
40import org.openstreetmap.josm.data.gpx.IGpxTrackSegment;
41import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
42import org.openstreetmap.josm.data.projection.Projection;
43import org.openstreetmap.josm.gui.MainApplication;
44import org.openstreetmap.josm.gui.MapView;
45import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
46import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
47import org.openstreetmap.josm.gui.io.importexport.GpxImporter;
48import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer;
49import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker;
50import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker;
51import org.openstreetmap.josm.gui.layer.gpx.ChooseTrackVisibilityAction;
52import org.openstreetmap.josm.gui.layer.gpx.ConvertFromGpxLayerAction;
53import org.openstreetmap.josm.gui.layer.gpx.CustomizeDrawingAction;
54import org.openstreetmap.josm.gui.layer.gpx.DownloadAlongTrackAction;
55import org.openstreetmap.josm.gui.layer.gpx.DownloadWmsAlongTrackAction;
56import org.openstreetmap.josm.gui.layer.gpx.GpxDrawHelper;
57import org.openstreetmap.josm.gui.layer.gpx.ImportAudioAction;
58import org.openstreetmap.josm.gui.layer.gpx.ImportImagesAction;
59import org.openstreetmap.josm.gui.layer.gpx.MarkersFromNamedPointsAction;
60import org.openstreetmap.josm.gui.layer.markerlayer.MarkerLayer;
61import org.openstreetmap.josm.gui.preferences.display.GPXSettingsPanel;
62import org.openstreetmap.josm.gui.util.GuiHelper;
63import org.openstreetmap.josm.gui.widgets.HtmlPanel;
64import org.openstreetmap.josm.tools.ImageProvider;
65import org.openstreetmap.josm.tools.Logging;
66import org.openstreetmap.josm.tools.Utils;
67import org.openstreetmap.josm.tools.date.Interval;
68
69/**
70 * A layer that displays data from a Gpx file / the OSM gpx downloads.
71 */
72public class GpxLayer extends AbstractModifiableLayer implements GpxDataContainer, ExpertModeChangeListener, JumpToMarkerLayer {
73
74 /** GPX data */
75 public GpxData data;
76 private boolean isLocalFile;
77 private boolean isExpertMode;
78
79 /**
80 * used by {@link ChooseTrackVisibilityAction} to determine which tracks to show/hide
81 * <p>
82 * Call {@link #invalidate()} after each change!
83 * <p>
84 * TODO: Make it private, make it respond to track changes.
85 */
86 public boolean[] trackVisibility = new boolean[0];
87 /**
88 * Added as field to be kept as reference.
89 */
90 private final GpxDataChangeListener dataChangeListener = new GpxDataChangeListener() {
91 @Override
92 public void gpxDataChanged(GpxDataChangeEvent e) {
93 invalidate();
94 }
95
96 @Override
97 public void modifiedStateChanged(boolean modified) {
98 GuiHelper.runInEDT(() -> propertyChangeSupport.firePropertyChange(REQUIRES_SAVE_TO_DISK_PROP, !modified, modified));
99 }
100 };
101 /**
102 * The MarkerLayer imported from the same file.
103 */
104 private MarkerLayer linkedMarkerLayer;
105
106 /**
107 * Current segment for {@link JumpToMarkerLayer}.
108 */
109 private IGpxTrackSegment currentSegment;
110
111 /**
112 * Constructs a new {@code GpxLayer} without name.
113 * @param d GPX data
114 */
115 public GpxLayer(GpxData d) {
116 this(d, null, false);
117 }
118
119 /**
120 * Constructs a new {@code GpxLayer} with a given name.
121 * @param d GPX data
122 * @param name layer name
123 */
124 public GpxLayer(GpxData d, String name) {
125 this(d, name, false);
126 }
127
128 /**
129 * Constructs a new {@code GpxLayer} with a given name, that can be attached to a local file.
130 * @param d GPX data
131 * @param name layer name
132 * @param isLocal whether data is attached to a local file
133 */
134 public GpxLayer(GpxData d, String name, boolean isLocal) {
135 super(name);
136 data = d;
137 data.addWeakChangeListener(dataChangeListener);
138 trackVisibility = new boolean[data.getTracks().size()];
139 Arrays.fill(trackVisibility, true);
140 isLocalFile = isLocal;
141 ExpertToggleAction.addExpertModeChangeListener(this, true);
142 }
143
144 @Override
145 public Color getColor() {
146 if (data == null)
147 return null;
148 Color[] c = data.getTracks().stream().map(IGpxTrack::getColor).distinct().toArray(Color[]::new);
149 return c.length == 1 ? c[0] : null; //only return if exactly one distinct color present
150 }
151
152 @Override
153 public void setColor(Color color) {
154 data.beginUpdate();
155 for (IGpxTrack trk : data.getTracks()) {
156 trk.setColor(color);
157 }
158 GPXSettingsPanel.putLayerPrefLocal(this, "colormode", "0");
159 data.endUpdate();
160 }
161
162 @Override
163 public boolean hasColor() {
164 return data != null;
165 }
166
167 /**
168 * Returns a human readable string that shows the timespan of the given track
169 * @param trk The GPX track for which timespan is displayed
170 * @return The timespan as a string
171 */
172 public static String getTimespanForTrack(IGpxTrack trk) {
173 return GpxData.getMinMaxTimeForTrack(trk).map(Interval::format).orElse("");
174 }
175
176 @Override
177 public Icon getIcon() {
178 return ImageProvider.get("layer", "gpx_small");
179 }
180
181 @Override
182 public Object getInfoComponent() {
183 StringBuilder info = new StringBuilder(128)
184 .append("<html><head><style>td { padding: 4px 16px; }</style></head><body>");
185
186 if (data != null) {
187 fillDataInfoComponent(info);
188 }
189
190 info.append("<br></body></html>");
191
192 final JScrollPane sp = new JScrollPane(new HtmlPanel(info.toString()));
193 sp.setPreferredSize(new Dimension(sp.getPreferredSize().width+20, 370));
194 SwingUtilities.invokeLater(() -> sp.getVerticalScrollBar().setValue(0));
195 return sp;
196 }
197
198 private void fillDataInfoComponent(StringBuilder info) {
199 if (data.attr.containsKey("name")) {
200 info.append(tr("Name: {0}", data.get(GpxConstants.META_NAME))).append("<br>");
201 }
202
203 if (data.attr.containsKey("desc")) {
204 info.append(tr("Description: {0}", data.get(GpxConstants.META_DESC))).append("<br>");
205 }
206
207 if (!Utils.isStripEmpty(data.creator)) {
208 info.append(tr("Creator: {0}", data.creator)).append("<br>");
209 }
210
211 if (!data.getTracks().isEmpty()) {
212 String tdSep = "</td><td>";
213 info.append("<table><thead align='center'><tr><td colspan='5'>")
214 .append(trn("{0} track, {1} track segments", "{0} tracks, {1} track segments",
215 data.getTrackCount(), data.getTrackCount(),
216 data.getTrackSegsCount(), data.getTrackSegsCount()))
217 .append("</td></tr><tr align='center'><td>").append(tr("Name"))
218 .append(tdSep).append(tr("Description"))
219 .append(tdSep).append(tr("Timespan"))
220 .append(tdSep).append(tr("Length"))
221 .append(tdSep).append(tr("Number of<br/>Segments"))
222 .append(tdSep).append(tr("URL"))
223 .append("</td></tr></thead>");
224
225 for (IGpxTrack trk : data.getTracks()) {
226 info.append("<tr><td>")
227 .append(trk.getAttributes().getOrDefault(GpxConstants.GPX_NAME, ""))
228 .append(tdSep)
229 .append(trk.getAttributes().getOrDefault(GpxConstants.GPX_DESC, ""))
230 .append(tdSep)
231 .append(getTimespanForTrack(trk))
232 .append(tdSep)
233 .append(SystemOfMeasurement.getSystemOfMeasurement().getDistText(trk.length()))
234 .append(tdSep)
235 .append(trk.getSegments().size())
236 .append(tdSep);
237 if (trk.getAttributes().containsKey("url")) {
238 info.append(trk.get("url"));
239 }
240 info.append("</td></tr>");
241 }
242 info.append("</table><br><br>");
243 }
244
245 info.append(tr("Length: {0}", SystemOfMeasurement.getSystemOfMeasurement().getDistText(data.length()))).append("<br>")
246 .append(trn("{0} route, ", "{0} routes, ", data.getRoutes().size(), data.getRoutes().size()))
247 .append(trn("{0} waypoint", "{0} waypoints", data.getWaypoints().size(), data.getWaypoints().size()));
248 }
249
250 @Override
251 public boolean isInfoResizable() {
252 return true;
253 }
254
255 @Override
256 public Action[] getMenuEntries() {
257 JumpToNextMarker jumpToNext = new JumpToNextMarker(this);
258 jumpToNext.putValue(Action.NAME, tr("Jump to next segment"));
259 JumpToPreviousMarker jumpToPrevious = new JumpToPreviousMarker(this);
260 jumpToPrevious.putValue(Action.NAME, tr("Jump to previous segment"));
261 List<Action> entries = new ArrayList<>(Arrays.asList(
262 LayerListDialog.getInstance().createShowHideLayerAction(),
263 LayerListDialog.getInstance().createDeleteLayerAction(),
264 MainApplication.getMenu().autoScaleActions.get(AutoScaleAction.AutoScaleMode.LAYER),
265 LayerListDialog.getInstance().createMergeLayerAction(this),
266 SeparatorLayerAction.INSTANCE,
267 new LayerSaveAction(this),
268 new LayerSaveAsAction(this),
269 new CustomizeColor(this),
270 new CustomizeDrawingAction(this),
271 new ImportImagesAction(this),
272 new ImportAudioAction(this),
273 new MarkersFromNamedPointsAction(this),
274 jumpToNext,
275 jumpToPrevious,
276 new ConvertFromGpxLayerAction(this),
277 new DownloadAlongTrackAction(Collections.singleton(data)),
278 new DownloadWmsAlongTrackAction(data),
279 SeparatorLayerAction.INSTANCE,
280 new ChooseTrackVisibilityAction(this),
281 new RenameLayerAction(getAssociatedFile(), this)));
282
283 List<Action> expert = Arrays.asList(
284 new CombineTracksToSegmentedTrackAction(this),
285 new SplitTrackSegmentsToTracksAction(this),
286 new SplitTracksToLayersAction(this));
287
288 if (isExpertMode && expert.stream().anyMatch(Action::isEnabled)) {
289 entries.add(SeparatorLayerAction.INSTANCE);
290 entries.addAll(expert.stream().filter(Action::isEnabled).collect(Collectors.toList()));
291 }
292
293 entries.add(SeparatorLayerAction.INSTANCE);
294 entries.add(new LayerListPopup.InfoAction(this));
295 return entries.toArray(new Action[0]);
296 }
297
298 /**
299 * Determines if data is attached to a local file.
300 * @return {@code true} if data is attached to a local file, {@code false} otherwise
301 */
302 public boolean isLocalFile() {
303 return isLocalFile;
304 }
305
306 @Override
307 public String getToolTipText() {
308 StringBuilder info = new StringBuilder(48).append("<html>");
309
310 if (data != null) {
311 fillDataToolTipText(info);
312 }
313
314 info.append("<br></html>");
315
316 return info.toString();
317 }
318
319 private void fillDataToolTipText(StringBuilder info) {
320 if (data.attr.containsKey(GpxConstants.META_NAME)) {
321 info.append(tr("Name: {0}", data.get(GpxConstants.META_NAME))).append("<br>");
322 }
323
324 if (data.attr.containsKey(GpxConstants.META_DESC)) {
325 info.append(tr("Description: {0}", data.get(GpxConstants.META_DESC))).append("<br>");
326 }
327
328 info.append(trn("{0} track", "{0} tracks", data.getTrackCount(), data.getTrackCount()))
329 .append(trn(" ({0} segment)", " ({0} segments)", data.getTrackSegsCount(), data.getTrackSegsCount()))
330 .append(", ")
331 .append(trn("{0} route, ", "{0} routes, ", data.getRoutes().size(), data.getRoutes().size()))
332 .append(trn("{0} waypoint", "{0} waypoints", data.getWaypoints().size(), data.getWaypoints().size())).append("<br>")
333 .append(tr("Length: {0}", SystemOfMeasurement.getSystemOfMeasurement().getDistText(data.length())));
334
335 if (Logging.isDebugEnabled() && !data.getLayerPrefs().isEmpty()) {
336 info.append("<br><br>")
337 .append(data.getLayerPrefs().entrySet().stream()
338 .map(e -> e.getKey() + "=" + e.getValue())
339 .collect(Collectors.joining("<br>")));
340 }
341 }
342
343 @Override
344 public boolean isMergable(Layer other) {
345 return data != null && other instanceof GpxLayer;
346 }
347
348 /**
349 * Shows/hides all tracks of a given date range by setting them to visible/invisible.
350 * @param fromDate The min date
351 * @param toDate The max date
352 * @param showWithoutDate Include tracks that don't have any date set..
353 */
354 public void filterTracksByDate(Instant fromDate, Instant toDate, boolean showWithoutDate) {
355 if (data == null)
356 return;
357 int i = 0;
358 long from = fromDate.toEpochMilli();
359 long to = toDate.toEpochMilli();
360 for (IGpxTrack trk : data.getTracks()) {
361 Interval t = GpxData.getMinMaxTimeForTrack(trk).orElse(null);
362
363 if (t == null) continue;
364 long tm = t.getEnd().toEpochMilli();
365 trackVisibility[i] = (tm == 0 && showWithoutDate) || (from <= tm && tm <= to);
366 i++;
367 }
368 invalidate();
369 }
370
371 @Override
372 public void mergeFrom(Layer from) {
373 if (!(from instanceof GpxLayer))
374 throw new IllegalArgumentException("not a GpxLayer: " + from);
375 mergeFrom((GpxLayer) from, false, false);
376 }
377
378 /**
379 * Merges the given GpxLayer into this layer and can remove timewise overlapping parts of the given track
380 * @param from The GpxLayer that gets merged into this one
381 * @param cutOverlapping whether overlapping parts of the given track should be removed
382 * @param connect whether the tracks should be connected on cuts
383 * @since 14338
384 */
385 public void mergeFrom(GpxLayer from, boolean cutOverlapping, boolean connect) {
386 data.mergeFrom(from.data, cutOverlapping, connect);
387 invalidate();
388 }
389
390 @Override
391 public String getLabel() {
392 return isDirty() ? super.getLabel() + ' ' + IS_DIRTY_SYMBOL : super.getLabel();
393 }
394
395 @Override
396 public void visitBoundingBox(BoundingXYVisitor v) {
397 if (data != null) {
398 v.visit(data.recalculateBounds());
399 }
400 }
401
402 @Override
403 public File getAssociatedFile() {
404 return data != null ? data.storageFile : null;
405 }
406
407 @Override
408 public void setAssociatedFile(File file) {
409 data.storageFile = file;
410 }
411
412 /**
413 * Returns the linked MarkerLayer.
414 * @return the linked MarkerLayer (imported from the same file)
415 * @since 15496
416 */
417 public MarkerLayer getLinkedMarkerLayer() {
418 return linkedMarkerLayer;
419 }
420
421 /**
422 * Sets the linked MarkerLayer.
423 * @param linkedMarkerLayer the linked MarkerLayer
424 * @since 15496
425 */
426 public void setLinkedMarkerLayer(MarkerLayer linkedMarkerLayer) {
427 this.linkedMarkerLayer = linkedMarkerLayer;
428 }
429
430 @Override
431 public void projectionChanged(Projection oldValue, Projection newValue) {
432 if (newValue == null || data == null) return;
433 data.resetEastNorthCache();
434 }
435
436 @Override
437 public boolean isSavable() {
438 return data != null; // With GpxExporter
439 }
440
441 @Override
442 public boolean checkSaveConditions() {
443 return data != null;
444 }
445
446 @Override
447 public File createAndOpenSaveFileChooser() {
448 return SaveActionBase.createAndOpenSaveFileChooser(tr("Save GPX file"), GpxImporter.getFileFilter());
449 }
450
451 @Override
452 public LayerPositionStrategy getDefaultLayerPosition() {
453 return LayerPositionStrategy.AFTER_LAST_DATA_LAYER;
454 }
455
456 @Override
457 public void paint(Graphics2D g, MapView mv, Bounds bbox) {
458 // unused - we use a painter so this is not called.
459 }
460
461 @Override
462 protected LayerPainter createMapViewPainter(MapViewEvent event) {
463 return new GpxDrawHelper(this);
464 }
465
466 /**
467 * Action to merge tracks into a single segmented track
468 *
469 * @since 13210
470 */
471 public static class CombineTracksToSegmentedTrackAction extends AbstractAction {
472 private final transient GpxLayer layer;
473
474 /**
475 * Create a new CombineTracksToSegmentedTrackAction
476 * @param layer The layer with the data to work on.
477 */
478 public CombineTracksToSegmentedTrackAction(GpxLayer layer) {
479 // FIXME: icon missing, create a new icon for this action
480 //new ImageProvider(..."gpx_tracks_to_segmented_track").getResource().attachImageIcon(this, true);
481 putValue(SHORT_DESCRIPTION, tr("Collect segments of all tracks and combine in a single track."));
482 putValue(NAME, tr("Combine tracks of this layer"));
483 this.layer = layer;
484 }
485
486 @Override
487 public void actionPerformed(ActionEvent e) {
488 layer.data.combineTracksToSegmentedTrack();
489 layer.invalidate();
490 }
491
492 @Override
493 public boolean isEnabled() {
494 return layer.data.getTrackCount() > 1;
495 }
496 }
497
498 /**
499 * Action to split track segments into a multiple tracks with one segment each
500 *
501 * @since 13210
502 */
503 public static class SplitTrackSegmentsToTracksAction extends AbstractAction {
504 private final transient GpxLayer layer;
505
506 /**
507 * Create a new SplitTrackSegmentsToTracksAction
508 * @param layer The layer with the data to work on.
509 */
510 public SplitTrackSegmentsToTracksAction(GpxLayer layer) {
511 // FIXME: icon missing, create a new icon for this action
512 //new ImageProvider(..."gpx_segmented_track_to_tracks").getResource().attachImageIcon(this, true);
513 putValue(SHORT_DESCRIPTION, tr("Split multiple track segments of one track into multiple tracks."));
514 putValue(NAME, tr("Split track segments to tracks"));
515 this.layer = layer;
516 }
517
518 @Override
519 public void actionPerformed(ActionEvent e) {
520 layer.data.splitTrackSegmentsToTracks(!layer.getName().isEmpty() ? layer.getName() : "GPX split result");
521 layer.invalidate();
522 }
523
524 @Override
525 public boolean isEnabled() {
526 return layer.data.getTrackSegsCount() > layer.data.getTrackCount();
527 }
528 }
529
530 /**
531 * Action to split tracks of one gpx layer into multiple gpx layers,
532 * the result is one GPX track per gpx layer.
533 *
534 * @since 13210
535 */
536 public static class SplitTracksToLayersAction extends AbstractAction {
537 private final transient GpxLayer layer;
538
539 /**
540 * Create a new SplitTrackSegmentsToTracksAction
541 * @param layer The layer with the data to work on.
542 */
543 public SplitTracksToLayersAction(GpxLayer layer) {
544 // FIXME: icon missing, create a new icon for this action
545 //new ImageProvider(..."gpx_split_tracks_to_layers").getResource().attachImageIcon(this, true);
546 putValue(SHORT_DESCRIPTION, tr("Split the tracks of this layer to one new layer each."));
547 putValue(NAME, tr("Split tracks to new layers"));
548 this.layer = layer;
549 }
550
551 @Override
552 public void actionPerformed(ActionEvent e) {
553 layer.data.splitTracksToLayers(!layer.getName().isEmpty() ? layer.getName() : "GPX split result");
554 // layer is not modified by this action
555 }
556
557 @Override
558 public boolean isEnabled() {
559 return layer.data.getTrackCount() > 1;
560 }
561 }
562
563 @Override
564 public void expertChanged(boolean isExpert) {
565 this.isExpertMode = isExpert;
566 }
567
568 @Override
569 public boolean isModified() {
570 return data != null && data.isModified();
571 }
572
573 @Override
574 public boolean requiresSaveToFile() {
575 return data != null && isModified() && (isLocalFile() || data.fromSession);
576 }
577
578 @Override
579 public void onPostSaveToFile() {
580 isLocalFile = true;
581 data.invalidate();
582 data.setModified(false);
583 }
584
585 @Override
586 public String getChangesetSourceTag() {
587 // no i18n for international values
588 return isLocalFile ? "survey" : null;
589 }
590
591 @Override
592 public Data getData() {
593 return data;
594 }
595
596 @Override
597 public GpxData getGpxData() {
598 return data;
599 }
600
601 /**
602 * Jump (move the viewport) to the next track segment.
603 */
604 @Override
605 public void jumpToNextMarker() {
606 if (data != null) {
607 jumpToNext(data.getTrackSegmentsStream().collect(Collectors.toList()));
608 }
609 }
610
611 /**
612 * Jump (move the viewport) to the previous track segment.
613 */
614 @Override
615 public void jumpToPreviousMarker() {
616 if (data != null) {
617 List<IGpxTrackSegment> segments = data.getTrackSegmentsStream().collect(Collectors.toList());
618 Collections.reverse(segments);
619 jumpToNext(segments);
620 }
621 }
622
623 private void jumpToNext(List<IGpxTrackSegment> segments) {
624 if (!segments.isEmpty() && currentSegment == null) {
625 currentSegment = segments.get(0);
626 MainApplication.getMap().mapView.zoomTo(currentSegment.getBounds());
627 } else if (!segments.isEmpty()) {
628 try {
629 int index = segments.indexOf(currentSegment);
630 currentSegment = segments.listIterator(index + 1).next();
631 MainApplication.getMap().mapView.zoomTo(currentSegment.getBounds());
632 } catch (IndexOutOfBoundsException | NoSuchElementException exception) {
633 Logging.trace(exception);
634 }
635 }
636 }
637
638 @Override
639 public synchronized void destroy() {
640 if (linkedMarkerLayer != null && MainApplication.getLayerManager().containsLayer(linkedMarkerLayer)) {
641 linkedMarkerLayer.data.transferLayerPrefs(data.getLayerPrefs());
642 }
643 data.clear();
644 data = null;
645 super.destroy();
646 }
647}
Note: See TracBrowser for help on using the repository browser.